@bongos/core 1.20.52 → 1.20.54
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 +61 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +92 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +37 -27
- package/docs/copy-registry.json +119 -29
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +58 -46
- package/migrations/core_267_page_tweak_author_of_record.sql +41 -0
- package/modules/government/board.js +15 -21
- package/modules/government/recommendations.js +218 -0
- package/modules/government/routes/government.js +47 -1
- package/modules/hall-ui/public/builders.js +3 -1
- package/modules/hall-ui/public/government.js +125 -2
- package/modules/lifecycle/db-analytics.js +6 -1
- package/modules/lifecycle/db-ship.js +10 -3
- package/modules/lifecycle/page-tweak-artist.js +86 -0
- package/modules/lifecycle/page-tweak-reads.js +7 -5
- package/modules/lifecycle/routes/tasks.js +3 -0
- package/modules/sessions/db.js +8 -0
- package/modules/status-ui/public/status.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/government_charter_library.mjs +11 -5
- package/tests/government_constitution_view.mjs +4 -1
- package/tests/government_recommendations.mjs +119 -0
- package/tests/government_routes.mjs +35 -0
- package/tests/page_tweak_artist.mjs +236 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// modules/lifecycle/page-tweak-artist.js — WHO a page-tweak round belongs to,
|
|
2
|
+
// written once (task 1004432 / WA6.RP02, ADR 0341 D10).
|
|
3
|
+
//
|
|
4
|
+
// A round has two people. The ARTIST wrote the words in the studio and holds the
|
|
5
|
+
// round's WEB claim (no worktree, no creating session: the claim the submit
|
|
6
|
+
// released). The APPLIER is the session that put the words into the page, on an
|
|
7
|
+
// ordinary CLI claim in a worktree. The artist is the author of record: they are
|
|
8
|
+
// the one paid (economy's awardArtistPageCredit), the one the changelog names
|
|
9
|
+
// (page-tweak-reads.js), and, since this task, the one `tasks.shipped_by` names
|
|
10
|
+
// once the round ships. The applier is a helper.
|
|
11
|
+
//
|
|
12
|
+
// Pure: SQL fragments and nothing else, so every reader takes the rule from here
|
|
13
|
+
// instead of re-typing it. Economy cannot require this file (a module reaches
|
|
14
|
+
// another only through a port), so credits.js keeps its own copy of the
|
|
15
|
+
// predicate and tests/page_tweak_artist.mjs holds the two strings together.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const PAGE_TWEAK_SOURCE = 'page-tweak';
|
|
20
|
+
|
|
21
|
+
// A WEB claim: the artist's. Neither a worktree (a CLI applier) nor a creating
|
|
22
|
+
// session (an agent-made claim) is on it.
|
|
23
|
+
function webClaimPredicate(alias = 'c') {
|
|
24
|
+
return `${alias}.worktree_name IS NULL AND ${alias}.creator_session_id IS NULL`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The newest claim first; the id breaks a same-instant tie the same way in
|
|
28
|
+
// every reader.
|
|
29
|
+
function latestFirst(alias = 'c') {
|
|
30
|
+
return `${alias}.claimed_at DESC, ${alias}.id DESC`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The round's artist: the builder of its latest web claim, as a scalar
|
|
34
|
+
// subquery over `taskIdExpr` (a column or a bind parameter). NULL when the
|
|
35
|
+
// round never had a web claim, which a caller treats as "no artist".
|
|
36
|
+
function artistIdSql(taskIdExpr) {
|
|
37
|
+
return `(SELECT c.builder_id FROM claims c
|
|
38
|
+
WHERE c.task_id = ${taskIdExpr} AND ${webClaimPredicate('c')}
|
|
39
|
+
ORDER BY ${latestFirst('c')}
|
|
40
|
+
LIMIT 1)`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// THE AUTHOR OF RECORD, as shipTask writes it (db-ship.js). Everywhere shipped
|
|
44
|
+
// work is counted or listed reads tasks.shipped_by: the profile, the shipped
|
|
45
|
+
// count, the activity log, the status page's recent list, streaks,
|
|
46
|
+
// achievements, the hub rollup. For an ordinary task that is the claim holder,
|
|
47
|
+
// set at confirm, and this keeps it (COALESCE with `fallbackParam`, the caller's
|
|
48
|
+
// builder). For a page tweak it is the ARTIST, written at the SHIPPED flip and
|
|
49
|
+
// nowhere earlier: until a round ships, shipped_by still names the applier,
|
|
50
|
+
// because the rebase gate (needsRebaseTasksForBuilder, status='confirmed') must
|
|
51
|
+
// refuse the session that can actually rebase, never an artist with no
|
|
52
|
+
// worktree. A round with no web claim keeps its applier. Used inside
|
|
53
|
+
// `UPDATE tasks`, so the row is `tasks`.
|
|
54
|
+
function shippedBySql(fallbackParam) {
|
|
55
|
+
return `CASE WHEN source = '${PAGE_TWEAK_SOURCE}'
|
|
56
|
+
THEN COALESCE(${artistIdSql('tasks.id')}, shipped_by, ${fallbackParam})
|
|
57
|
+
ELSE COALESCE(shipped_by, ${fallbackParam}) END`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The helper beside a shipped row, for a list that shows people. The helper is
|
|
61
|
+
// the round's APPLIER, the builder of its latest WORKTREE claim: display only
|
|
62
|
+
// ("with help from"), never attribution or pay. A LEFT JOIN
|
|
63
|
+
// LATERAL yielding helper_login and helper_display_name on a page tweak whose
|
|
64
|
+
// applier is someone other than the author of record, and nothing otherwise
|
|
65
|
+
// (an ordinary task, a self-applied round, a round with no worktree claim).
|
|
66
|
+
// `alias` is the tasks alias of the surrounding query.
|
|
67
|
+
//
|
|
68
|
+
// THE COST, since it runs once per row of a feed. The source test sits inside
|
|
69
|
+
// the claims scan and names only the outer row, so Postgres plans it as a
|
|
70
|
+
// one-time filter: an ordinary task reads no claim at all. A page tweak's lookup
|
|
71
|
+
// is idx_claims_task_all (core_263, claims (task_id)) plus a sort of that one
|
|
72
|
+
// task's few claims, and the feeds are LIMITed.
|
|
73
|
+
function helperJoinSql(alias = 't') {
|
|
74
|
+
return `LEFT JOIN LATERAL (
|
|
75
|
+
SELECT hb.github_login AS helper_login, hb.display_name AS helper_display_name
|
|
76
|
+
FROM (SELECT c.builder_id FROM claims c
|
|
77
|
+
WHERE ${alias}.source = '${PAGE_TWEAK_SOURCE}'
|
|
78
|
+
AND c.task_id = ${alias}.id AND c.worktree_name IS NOT NULL
|
|
79
|
+
ORDER BY ${latestFirst('c')}
|
|
80
|
+
LIMIT 1) ap
|
|
81
|
+
JOIN builders hb ON hb.id = ap.builder_id
|
|
82
|
+
WHERE ap.builder_id IS DISTINCT FROM ${alias}.shipped_by
|
|
83
|
+
) helper ON true`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { PAGE_TWEAK_SOURCE, webClaimPredicate, latestFirst, artistIdSql, shippedBySql, helperJoinSql };
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const api = require('../../src/module-api');
|
|
15
15
|
const hold = require('./page-tweak-hold');
|
|
16
|
+
const artistRule = require('./page-tweak-artist');
|
|
16
17
|
|
|
17
18
|
// Every page-tweak task, oldest first, with what the round state (D7) and the
|
|
18
19
|
// changelog (D8) need beside the task itself:
|
|
@@ -35,9 +36,10 @@ const hold = require('./page-tweak-hold');
|
|
|
35
36
|
// * the active claim is a plain join: uniq_active_claim_per_task allows at
|
|
36
37
|
// most one row per task, and its partial index serves it.
|
|
37
38
|
// * the latest web claim is ONE pass over claims (the `web` CTE, DISTINCT ON
|
|
38
|
-
// per task), joined once, rather than a correlated subquery per task
|
|
39
|
-
// claims
|
|
40
|
-
//
|
|
39
|
+
// per task), joined once, rather than a correlated subquery per task.
|
|
40
|
+
// (When this was written claims had no full index on task_id; core_263 has
|
|
41
|
+
// since added idx_claims_task_all, so a per-task lookup is an index probe.
|
|
42
|
+
// The single pass stays: it is still the cheaper plan for every round at once.)
|
|
41
43
|
//
|
|
42
44
|
// `pageId` narrows it to ONE page's rounds (task 1004319 / TW08): the draft
|
|
43
45
|
// autosave runs this on every debounced save, and a page's rounds are a handful
|
|
@@ -52,8 +54,8 @@ async function listPageTweakTasks({ pageId = null } = {}) {
|
|
|
52
54
|
), web AS (
|
|
53
55
|
SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
|
|
54
56
|
FROM claims c JOIN pt ON pt.id = c.task_id
|
|
55
|
-
WHERE
|
|
56
|
-
ORDER BY c.task_id,
|
|
57
|
+
WHERE ${artistRule.webClaimPredicate('c')}
|
|
58
|
+
ORDER BY c.task_id, ${artistRule.latestFirst('c')}
|
|
57
59
|
)
|
|
58
60
|
SELECT t.id, t.status, t.title, t.description, t.source_ref, t.touches,
|
|
59
61
|
t.shipped_at, t.created_at, t.updated_at,
|
|
@@ -33,6 +33,7 @@ const { depRefusalRow } = require('../dead-deps.js');
|
|
|
33
33
|
// quality verdict rather than an outage or an errored worker (task 1002652).
|
|
34
34
|
// The acclaim feed reads it so a grader outage never reaches a voter as 0.0.
|
|
35
35
|
const { gradeQualityWhere } = require('../db-shared');
|
|
36
|
+
const { helperJoinSql } = require('../page-tweak-artist'); // task 1004432: a page tweak's applier as a helper
|
|
36
37
|
const auth = api;
|
|
37
38
|
const db = require('../db');
|
|
38
39
|
const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
|
|
@@ -783,6 +784,7 @@ module.exports = function buildTasksRouter() {
|
|
|
783
784
|
`SELECT t.id, t.title, t.value_summary, t.version_id, t.status,
|
|
784
785
|
t.shipped_at, t.shipped_by,
|
|
785
786
|
b.github_login, b.display_name,
|
|
787
|
+
helper.helper_login, helper.helper_display_name,
|
|
786
788
|
COALESCE(pv.net, 0)::int AS net_votes,
|
|
787
789
|
mine.vote AS my_vote,
|
|
788
790
|
-- task 1002835: a karma vote is uninformed without the two facts
|
|
@@ -801,6 +803,7 @@ module.exports = function buildTasksRouter() {
|
|
|
801
803
|
cost.total_usd AS cost_usd
|
|
802
804
|
FROM tasks t
|
|
803
805
|
LEFT JOIN builders b ON b.id = t.shipped_by
|
|
806
|
+
${helperJoinSql('t')}
|
|
804
807
|
LEFT JOIN task_grades g ON g.task_id = t.id
|
|
805
808
|
LEFT JOIN LATERAL (
|
|
806
809
|
SELECT SUM(amount_usd) AS total_usd
|
package/modules/sessions/db.js
CHANGED
|
@@ -741,6 +741,10 @@ async function sessionUploadCoverage({ days, windowHours } = {}) {
|
|
|
741
741
|
AND t.shipped_by IS NOT NULL
|
|
742
742
|
AND t.shipped_at IS NOT NULL
|
|
743
743
|
AND t.shipped_at >= now() - ($1 * interval '1 day')
|
|
744
|
+
-- task 1004432: a shipped page tweak names its ARTIST, who writes in the
|
|
745
|
+
-- studio and runs no session, so it can never be "covered"; the session
|
|
746
|
+
-- that applied it is the applier's, on an ordinary task of its own kind.
|
|
747
|
+
AND t.source IS DISTINCT FROM 'page-tweak'
|
|
744
748
|
GROUP BY t.shipped_by, b.github_login
|
|
745
749
|
ORDER BY 4 DESC`,
|
|
746
750
|
[d, w]
|
|
@@ -765,6 +769,10 @@ async function shipsMissingSessionRecord({ days, windowHours, limit, builderId }
|
|
|
765
769
|
AND t.shipped_by IS NOT NULL
|
|
766
770
|
AND t.shipped_at IS NOT NULL
|
|
767
771
|
AND t.shipped_at >= now() - ($1 * interval '1 day')
|
|
772
|
+
-- task 1004432: a shipped page tweak names its ARTIST, who writes in the
|
|
773
|
+
-- studio and runs no session, so it can never be "covered"; the session
|
|
774
|
+
-- that applied it is the applier's, on an ordinary task of its own kind.
|
|
775
|
+
AND t.source IS DISTINCT FROM 'page-tweak'
|
|
768
776
|
AND ($3::bigint IS NULL OR t.shipped_by = $3)
|
|
769
777
|
AND NOT EXISTS (
|
|
770
778
|
SELECT 1
|
|
@@ -984,7 +984,9 @@ function renderShippedPage() {
|
|
|
984
984
|
const meta = document.createElement('p');
|
|
985
985
|
meta.className = 'shipped__meta';
|
|
986
986
|
const by = t.display_name || t.github_login;
|
|
987
|
-
|
|
987
|
+
// task 1004432: an approved page is the artist's; the session that applied it is a helper.
|
|
988
|
+
const helper = t.helper_display_name || t.helper_login;
|
|
989
|
+
meta.textContent = `${by ? `inscribed by ${by} · ` : ''}${helper ? `with help from ${helper} · ` : ''}${fmtRelativeTime(t.shipped_at)}`;
|
|
988
990
|
li.appendChild(title);
|
|
989
991
|
if (thumb) li.appendChild(thumb);
|
|
990
992
|
li.appendChild(meta);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.54",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.54",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.54",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8429,5 +8429,17 @@
|
|
|
8429
8429
|
"id": "1004475",
|
|
8430
8430
|
"text": "Equity or shares is now also greyed out for a single-owner company and for a research entity, each with its own short reason. Only a regular for-profit company or a division of a corporation is offered equity. As before, it's"
|
|
8431
8431
|
}
|
|
8432
|
+
],
|
|
8433
|
+
"1.20.53": [
|
|
8434
|
+
{
|
|
8435
|
+
"id": "1004432",
|
|
8436
|
+
"text": "An approved page now counts as the artist's own work everywhere the project shows who did what: their profile, their shipped count, their activity, the status page and the leaderboard. Before, the page was credited to the sess"
|
|
8437
|
+
}
|
|
8438
|
+
],
|
|
8439
|
+
"1.20.54": [
|
|
8440
|
+
{
|
|
8441
|
+
"id": "1004400",
|
|
8442
|
+
"text": "The Government page now recommends a starting government and a starting set of ranks for your kind of project (game, research, business, non-profit) and its size. It only ever recommends a form you can adopt today. A team of a"
|
|
8443
|
+
}
|
|
8432
8444
|
]
|
|
8433
8445
|
}
|
|
@@ -253,6 +253,7 @@ const GOVERNMENT_PINS = {
|
|
|
253
253
|
'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
254
254
|
'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
255
255
|
'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
|
|
256
|
+
'POST /government/rank-sets/apply': 'perm:government.manage',
|
|
256
257
|
'POST /government/assignments': 'perm:government.manage',
|
|
257
258
|
'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
|
|
258
259
|
// The Board Room's first route (R08 of goal 1000069 — the R04-carried pin).
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -24,11 +24,16 @@ test('the ladder bands track exactly the thresholds the synthesis names', () =>
|
|
|
24
24
|
assert.equal(recommendedCharter(2).key, 'monarchy', 'this project today: two builders → the founding monarchy');
|
|
25
25
|
assert.equal(recommendedCharter(3).key, 'direct-democracy', 'the circle rung starts at 3');
|
|
26
26
|
assert.equal(recommendedCharter(8).key, 'direct-democracy');
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// ~9 and ~150 are where the ladder's own shapes (Presidential, Federation)
|
|
28
|
+
// cannot be adopted yet, so the recommendation names the nearest form that
|
|
29
|
+
// can — and says what is coming in `later` (task 1004400).
|
|
30
|
+
assert.equal(recommendedCharter(9).key, 'timocratic-republic', '~9: the circle gets too big');
|
|
31
|
+
assert.equal(recommendedCharter(9).later.key, 'presidential');
|
|
32
|
+
assert.equal(recommendedCharter(49).key, 'timocratic-republic');
|
|
29
33
|
assert.equal(recommendedCharter(50).key, 'foundation', '~50: written charter + council');
|
|
30
34
|
assert.equal(recommendedCharter(149).key, 'foundation');
|
|
31
|
-
assert.equal(recommendedCharter(150).key, '
|
|
35
|
+
assert.equal(recommendedCharter(150).key, 'foundation', '~150: a council carries it until federation exists');
|
|
36
|
+
assert.equal(recommendedCharter(150).later.key, 'federation');
|
|
32
37
|
assert.equal(recommendedCharter(0).key, 'monarchy', 'a zero/unknown headcount fails to the founding shape');
|
|
33
38
|
assert.equal(recommendedCharter('nonsense').key, 'monarchy');
|
|
34
39
|
});
|
|
@@ -74,7 +79,7 @@ test('the library presents all seven structures with honest selectable marks (cl
|
|
|
74
79
|
const entry = b.split('{ key:')[0];
|
|
75
80
|
assert.ok(entry.includes('how:'), 'a selectable structure names its configuration');
|
|
76
81
|
}
|
|
77
|
-
assert.match(client, /recommended
|
|
82
|
+
assert.match(client, /recommended for this type and size/, 'the one live datum is rendered');
|
|
78
83
|
assert.match(client, /c\.key === recommendedKey/, "the pill is driven by the SERVER's recommendation, never re-derived client-side");
|
|
79
84
|
for (const t of ['Locked', 'Laddered', 'Scheduled']) {
|
|
80
85
|
assert.ok(client.includes(`name: '${t}'`), `trajectory ${t} present`);
|
|
@@ -171,7 +176,8 @@ test('the recommendation names a charter the config can actually express', () =>
|
|
|
171
176
|
const rec = recommendedCharter(n);
|
|
172
177
|
const charter = by[rec.key];
|
|
173
178
|
assert.ok(charter, `headcount ${n} recommends "${rec.key}", which is not in the library`);
|
|
174
|
-
|
|
179
|
+
assert.ok(charter.selectable, `headcount ${n} recommends "${rec.key}", which cannot be adopted (task 1004400)`);
|
|
180
|
+
{
|
|
175
181
|
assert.ok(charter.predicate !== undefined,
|
|
176
182
|
`headcount ${n} recommends selectable "${rec.key}" — it must carry a checkable predicate`);
|
|
177
183
|
}
|
|
@@ -82,7 +82,10 @@ test('the read rides the vote atom, and the client renders the SERVER value (sou
|
|
|
82
82
|
assert.equal(rrc.MODULE_EXPECTED_RANKS.government['GET /government/constitution'],
|
|
83
83
|
`${rrc.PERM_PIN_PREFIX}board.vote.cast`);
|
|
84
84
|
const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
85
|
-
|
|
85
|
+
// Since task 1004400 the path may carry ?type= / &headcount= (advice-only
|
|
86
|
+
// inputs for the recommendation), so constitutionPath() builds it.
|
|
87
|
+
assert.match(client, /fetchJson\(constitutionPath\(\)\)/, 'the panel loads the resolved value');
|
|
88
|
+
assert.match(client, /`\/government\/constitution\$\{qs/, 'from the constitution read, nowhere else');
|
|
86
89
|
assert.ok(!/'first_ratifier'[^]*hardcod/i.test(client), 'sanity');
|
|
87
90
|
assert.match(client, /loadConstitution\(\)/, 'the panel loads for everyone the page admits');
|
|
88
91
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/government_recommendations.mjs — recommended governments and base-rank
|
|
2
|
+
// sets by project TYPE and SIZE (task 1004400, criterion wa7-government).
|
|
3
|
+
//
|
|
4
|
+
// The rules held here, each because the live site once broke it:
|
|
5
|
+
// • a recommendation only ever names a charter that can be ADOPTED — the
|
|
6
|
+
// ladder used to recommend Presidential (~9) and Federation (150+), and the
|
|
7
|
+
// live project, at 10 builders, was told to adopt one nobody can;
|
|
8
|
+
// • the type really changes the answer, not just the wording;
|
|
9
|
+
// • every grant a recommended rank carries is one a custom rank can hold;
|
|
10
|
+
// • a Foundation is always recommended together with the council rank it is
|
|
11
|
+
// governed through, and that rank makes a valid charter proposal.
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
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 reco = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
|
|
22
|
+
const charter = require(path.join(ROOT, 'modules', 'government', 'charter.js'));
|
|
23
|
+
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
24
|
+
const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
25
|
+
|
|
26
|
+
const TYPES = reco.PROJECT_TYPES.map((t) => t.key);
|
|
27
|
+
// Every band edge, plus nonsense — the edges are where a ladder goes wrong.
|
|
28
|
+
const HEADCOUNTS = [0, 1, 2, 3, 8, 9, 10, 49, 50, 149, 150, 151, 1000, -4, 'nonsense', null];
|
|
29
|
+
const CUSTOM_RANK = { is_system: false, seeded_rank: null };
|
|
30
|
+
|
|
31
|
+
// The hall's charter library says which charters are selectable; read it from
|
|
32
|
+
// the client source, since browser code cannot be required.
|
|
33
|
+
function hallSelectableKeys() {
|
|
34
|
+
const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
35
|
+
const lib = client.slice(client.indexOf('const CHARTERS'), client.indexOf('const TRAJECTORIES'));
|
|
36
|
+
return [...lib.matchAll(/key: '([a-z-]+)', name: '[^']+', selectable: true/g)].map((m) => m[1]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('the project types are exactly the ones a project declares at creation', () => {
|
|
40
|
+
assert.deepEqual(TYPES, provisioning.PROJECT_TYPES);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('every type and size recommends a charter that can be adopted today', () => {
|
|
44
|
+
const available = charter.FORMS.filter((f) => f.available).map((f) => f.key);
|
|
45
|
+
const selectable = hallSelectableKeys();
|
|
46
|
+
assert.ok(selectable.length >= 4, 'the hall library parse found its selectable charters');
|
|
47
|
+
for (const type of [...TYPES, undefined, 'spaceship']) {
|
|
48
|
+
for (const n of HEADCOUNTS) {
|
|
49
|
+
const c = reco.recommendedCharter(n, type);
|
|
50
|
+
assert.ok(available.includes(c.key), `${type}@${n} recommends "${c.key}", which charter.js cannot adopt`);
|
|
51
|
+
assert.ok(selectable.includes(c.key), `${type}@${n} recommends "${c.key}", which the hall marks not selectable`);
|
|
52
|
+
if (c.later) assert.ok(!available.includes(c.later.key), 'a `later` shape is one that cannot be adopted yet');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the live case: a project of 10 is no longer told to adopt Presidential', () => {
|
|
58
|
+
const c = reco.recommendedCharter(10, 'not-sure');
|
|
59
|
+
assert.equal(c.key, 'timocratic-republic');
|
|
60
|
+
assert.equal(c.later.key, 'presidential', 'what the ladder would name is still said, as coming later');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the project type changes the answer, not just the words', () => {
|
|
64
|
+
assert.equal(reco.recommendedCharter(20, 'game').key, 'timocratic-republic');
|
|
65
|
+
assert.equal(reco.recommendedCharter(20, 'non-profit').key, 'foundation', 'a non-profit is governed by trustees early');
|
|
66
|
+
assert.equal(reco.recommendedRankSet('game', 5).ranks[0].rank_key, 'playtester');
|
|
67
|
+
assert.equal(reco.recommendedRankSet('research', 5).ranks[0].rank_key, 'peer-reviewer');
|
|
68
|
+
assert.equal(reco.recommendedCharter(20, 'spaceship').type, 'not-sure', 'an unknown type reads as not sure');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the rank set grows with size', () => {
|
|
72
|
+
const sizes = [1, 5, 20, 80, 200].map((n) => reco.recommendedRankSet('business', n).ranks.length);
|
|
73
|
+
assert.deepEqual(sizes, [0, 1, 2, 3, 3]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('every recommended rank is creatable and every grant is one a custom rank can hold', () => {
|
|
77
|
+
for (const type of TYPES) {
|
|
78
|
+
for (const n of HEADCOUNTS) {
|
|
79
|
+
for (const r of reco.recommendedRankSet(type, n).ranks) {
|
|
80
|
+
assert.match(r.rank_key, /^[a-z][a-z0-9-]*$/, `${r.rank_key} must be a slug the rank API accepts`);
|
|
81
|
+
assert.ok(r.rank_key.length >= 2 && r.rank_key.length <= 64);
|
|
82
|
+
assert.ok(!catalog.RANK_ORDER.includes(r.rank_key), `${r.rank_key} must not collide with a standard rank`);
|
|
83
|
+
assert.ok(r.label && r.description);
|
|
84
|
+
assert.ok(r.permissions.length > 0);
|
|
85
|
+
for (const key of r.permissions) {
|
|
86
|
+
assert.ok(catalog.byKey(key), `${r.rank_key}: ${key} is not in the catalog`);
|
|
87
|
+
assert.ok(catalog.isGrantableTo(key, CUSTOM_RANK), `${r.rank_key}: ${key} cannot be granted to a custom rank`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a recommended Foundation always comes with its council rank, and that rank makes a valid charter', () => {
|
|
95
|
+
const live = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
96
|
+
let foundations = 0;
|
|
97
|
+
for (const type of TYPES) {
|
|
98
|
+
for (const n of HEADCOUNTS) {
|
|
99
|
+
const c = reco.recommendedCharter(n, type);
|
|
100
|
+
if (c.key !== 'foundation') { assert.equal(c.council_rank, null); continue; }
|
|
101
|
+
foundations += 1;
|
|
102
|
+
const set = reco.recommendedRankSet(type, n).ranks.map((r) => r.rank_key);
|
|
103
|
+
assert.ok(set.includes(c.council_rank), `${type}@${n}: the rank set must carry ${c.council_rank}`);
|
|
104
|
+
const built = charter.buildCharterProposal(live, { form: 'foundation', councilRank: c.council_rank });
|
|
105
|
+
assert.ok(built.ok, `${type}@${n}: ${c.council_rank} must make a valid Foundation (${built.reason})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
assert.ok(foundations > 0, 'the walk reached a Foundation');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('recommendationsFor carries both answers and the vocabulary to ask about another', () => {
|
|
112
|
+
const r = reco.recommendationsFor({ type: 'research', headcount: 60 });
|
|
113
|
+
assert.equal(r.type, 'research');
|
|
114
|
+
assert.equal(r.size, 'organisation');
|
|
115
|
+
assert.equal(r.charter.key, 'foundation');
|
|
116
|
+
assert.equal(r.rank_set.ranks.length, 3);
|
|
117
|
+
assert.equal(r.project_types.length, TYPES.length);
|
|
118
|
+
for (const b of r.size_bands) assert.equal(reco.sizeBandFor(b.example).key, b.key, 'each size option lands in its own band');
|
|
119
|
+
});
|
|
@@ -60,6 +60,7 @@ test('the routes file declares the full R110 surface', () => {
|
|
|
60
60
|
'POST /government/ranks/:rankKey/reset',
|
|
61
61
|
'POST /government/assignments',
|
|
62
62
|
'DELETE /government/assignments/:builderId/:rankKey',
|
|
63
|
+
'POST /government/rank-sets/apply',
|
|
63
64
|
];
|
|
64
65
|
for (const key of wanted) assert.ok(declared.has(key), `missing route ${key}`);
|
|
65
66
|
});
|
|
@@ -203,6 +204,7 @@ test('without government.manage EVERY route 403s (reads included)', async () =>
|
|
|
203
204
|
['DELETE', '/government/ranks/r/permissions/task.create'],
|
|
204
205
|
['POST', '/government/assignments'],
|
|
205
206
|
['DELETE', '/government/assignments/7/r'],
|
|
207
|
+
['POST', '/government/rank-sets/apply'],
|
|
206
208
|
]) {
|
|
207
209
|
const r = await call(method, p, method === 'GET' ? undefined : {});
|
|
208
210
|
assert.equal(r.status, 403, `${method} ${p} must deny`);
|
|
@@ -243,6 +245,39 @@ test('GET /government/assignments returns builder↔role rows', async () => {
|
|
|
243
245
|
assert.equal(r.json.assignments[0].rank_key, 'metic');
|
|
244
246
|
});
|
|
245
247
|
|
|
248
|
+
// --- recommended rank sets (task 1004400) -------------------------------------
|
|
249
|
+
|
|
250
|
+
test('POST /government/rank-sets/apply creates the missing ranks with their grants and leaves existing ones alone', async () => {
|
|
251
|
+
const realGet = stub.getRankByKey;
|
|
252
|
+
// 'design-lead' already exists: it must be skipped, never re-granted.
|
|
253
|
+
stub.getRankByKey = async (k) => (k === 'design-lead' ? { rank_key: k, label: 'Mine', is_system: false, seeded_rank: null } : null);
|
|
254
|
+
try {
|
|
255
|
+
const r = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 });
|
|
256
|
+
assert.equal(r.status, 200);
|
|
257
|
+
assert.deepEqual(r.json.skipped, ['design-lead']);
|
|
258
|
+
assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
|
|
259
|
+
const creates = calls.filter(([k]) => k === 'createRank');
|
|
260
|
+
assert.equal(creates.length, 1);
|
|
261
|
+
assert.equal(creates[0][1].createdBy, 42, 'created_by is the session builder');
|
|
262
|
+
const grants = calls.filter(([k]) => k === 'grantRankPermission').map(([, a]) => a);
|
|
263
|
+
assert.ok(grants.length > 0 && grants.every((g) => g.rankKey === 'playtester'), 'an existing rank is never re-granted');
|
|
264
|
+
assert.ok(grants.every((g) => catalog.isGrantableTo(g.permissionKey, { is_system: false, seeded_rank: null })));
|
|
265
|
+
} finally { stub.getRankByKey = realGet; }
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('POST /government/rank-sets/apply needs a headcount and does nothing at founding size', async () => {
|
|
269
|
+
const bad = await call('POST', '/government/rank-sets/apply', { type: 'game' });
|
|
270
|
+
assert.equal(bad.status, 400);
|
|
271
|
+
const realGet = stub.getRankByKey;
|
|
272
|
+
stub.getRankByKey = async () => null;
|
|
273
|
+
try {
|
|
274
|
+
const solo = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 });
|
|
275
|
+
assert.equal(solo.status, 200);
|
|
276
|
+
assert.deepEqual(solo.json.created, []);
|
|
277
|
+
assert.equal(calls.filter(([k]) => k === 'createRank').length, 0);
|
|
278
|
+
} finally { stub.getRankByKey = realGet; }
|
|
279
|
+
});
|
|
280
|
+
|
|
246
281
|
// --- create role -------------------------------------------------------------
|
|
247
282
|
|
|
248
283
|
test('POST /government/ranks creates a custom role, created_by = the session builder', async () => {
|