@bongos/core 1.19.933 → 1.19.934
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 +39 -29
- package/docs/copy-inventory.md +14 -14
- package/docs/copy-registry.json +14 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/government/board.js +32 -7
- package/modules/government/migrations/government_017_board_section_keys_v2.sql +59 -0
- package/modules/hall-ui/public/board-room.js +72 -15
- package/modules/ideas/migrations/ideas_008_template_default_v2.sql +37 -0
- package/modules/ideas/projection.js +16 -3
- package/modules/ideas/templates.js +80 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_board_room_copy.mjs +12 -3
- package/tests/government_board_vote.mjs +53 -5
- package/tests/government_board_vote_ui.mjs +26 -6
- package/tests/government_seed.mjs +1 -0
- package/tests/helpers.mjs +23 -0
- package/tests/idea_completeness.mjs +30 -13
- package/tests/idea_grades.mjs +7 -2
- package/tests/idea_hall_composer.mjs +45 -5
- package/tests/idea_ideate_full.mjs +13 -4
- package/tests/idea_resubmit.mjs +49 -10
- package/tests/idea_spark_hall.mjs +3 -1
- package/tests/idea_templates.mjs +95 -16
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// TASK 1003270's two rules came with the card and still govern edits here:
|
|
28
28
|
// • NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` /
|
|
29
29
|
// `why_matters` are grammar for the server; a person gets the sentence
|
|
30
|
-
// (decidedByHtml, rankNames,
|
|
30
|
+
// (decidedByHtml, rankNames, SECTION_LABELS_BY_TEMPLATE). The single exception is a
|
|
31
31
|
// membership predicate that cannot be PARSED, where the broken string is
|
|
32
32
|
// the actionable fact.
|
|
33
33
|
// • COPY THAT EXPLAINS THE UI TO ITSELF IS DELETED, not shortened.
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
// `fetchJson` / `sendJson` stay per-page: a hall page script is a self-contained
|
|
51
51
|
// IIFE over the DOM, and those two carry this page's own error copy.
|
|
52
52
|
//
|
|
53
|
-
//
|
|
54
|
-
// only reader, and the lib is for what the two pages share.
|
|
53
|
+
// SECTION_LABELS_BY_TEMPLATE and fmtRemaining stay HERE, not in the lib — this
|
|
54
|
+
// page is their only reader, and the lib is for what the two pages share.
|
|
55
55
|
//
|
|
56
56
|
// The function ORDER here is deliberate and matches the pre-carve source:
|
|
57
57
|
// tests/government_board_room_copy.mjs slices this file by function-name
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
// wrote under and the objector picked from. `why_matters` is a jsonb key,
|
|
166
166
|
// not something a reader should have to decode.
|
|
167
167
|
const objection = v.direction === 'no'
|
|
168
|
-
? `<p class="ov-row__sub">objected to <strong>${escapeHtml(
|
|
168
|
+
? `<p class="ov-row__sub">objected to <strong>${escapeHtml(labelsOf(item)[v.section_key] || ANY_SECTION_LABEL[v.section_key] || v.section_key || 'the idea')}</strong>: ${escapeHtml(v.reason_md || '')}</p>`
|
|
169
169
|
: '';
|
|
170
170
|
return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
|
|
171
171
|
<span class="ov-row__title">${who}${mine}</span>
|
|
@@ -178,17 +178,59 @@
|
|
|
178
178
|
|
|
179
179
|
// ---- the vote form (R17) -----------------------------------------------------
|
|
180
180
|
|
|
181
|
-
// The
|
|
181
|
+
// The section labels, mirrored from the template declarations
|
|
182
182
|
// (modules/ideas/templates.js — the words the author saw when writing; a test
|
|
183
183
|
// holds the two equal, since browser code cannot import a server module).
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
//
|
|
185
|
+
// ⚠ ONE MAP PER VINTAGE, KEYED BY `template_key` (task 1004085 / BV2.R06).
|
|
186
|
+
// There used to be one flat map, which was right while there was one template.
|
|
187
|
+
// It cannot be flattened again: `why_had` and `fit` exist in BOTH vintages with
|
|
188
|
+
// DIFFERENT questions behind them, so a merged map would show one idea's author
|
|
189
|
+
// the words a different author was asked. The label is a quotation, and the
|
|
190
|
+
// thing it quotes is the template the subject was actually written against.
|
|
191
|
+
const SECTION_LABELS_BY_TEMPLATE = {
|
|
192
|
+
'base-v1': {
|
|
193
|
+
purpose: 'Purpose of Idea',
|
|
194
|
+
big_picture: 'What does the Big Picture look like',
|
|
195
|
+
why_had: 'Why did you have the Idea',
|
|
196
|
+
why_matters: 'Why does the Idea Matter',
|
|
197
|
+
fit: 'How does the Idea Fit into the big picture of the project',
|
|
198
|
+
},
|
|
199
|
+
'base-v2': {
|
|
200
|
+
why_had: 'What made you have the idea',
|
|
201
|
+
scope: 'Scope it as best as you can here',
|
|
202
|
+
build_context: 'What should an engineer or artist know prior to building',
|
|
203
|
+
fit: 'How is this long term justified in the project',
|
|
204
|
+
success: 'What does a successful implementation of the idea look like',
|
|
205
|
+
},
|
|
190
206
|
};
|
|
191
207
|
|
|
208
|
+
// The vintage an item's sections should be read under.
|
|
209
|
+
//
|
|
210
|
+
// THE FALLBACK IS `base-v1`, AND IT IS NOT THE SERVER'S DEFAULT. An item with
|
|
211
|
+
// no template to read is an AMENDMENT (which has no Full Idea behind it at all,
|
|
212
|
+
// yet still needs a section picker, because the DB requires every objection to
|
|
213
|
+
// name one) or a full_idea whose subject row was deleted. Falling back to the
|
|
214
|
+
// current default would silently change what those items have always offered;
|
|
215
|
+
// falling back to v1 keeps them byte-identical to before this task. A vintage
|
|
216
|
+
// this shipped page has never heard of falls here too — see sectionsOf.
|
|
217
|
+
function templateOf(item) {
|
|
218
|
+
const key = item && item.subject && item.subject.template_key;
|
|
219
|
+
return SECTION_LABELS_BY_TEMPLATE[key] ? key : 'base-v1';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function labelsOf(item) {
|
|
223
|
+
return SECTION_LABELS_BY_TEMPLATE[templateOf(item)];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// DECODE-ONLY, and deliberately not used to render a form. A stored objection
|
|
227
|
+
// names a section that was legal when it was cast, which on an item whose
|
|
228
|
+
// vintage this page cannot resolve is a key no per-vintage map holds. Turning
|
|
229
|
+
// it into SOME readable words beats printing `why_matters` at a reader, and the
|
|
230
|
+
// ambiguity the per-vintage maps exist to avoid does not arise here: the item's
|
|
231
|
+
// own map is consulted first, and this is only the fallback behind it.
|
|
232
|
+
const ANY_SECTION_LABEL = Object.assign({}, ...Object.values(SECTION_LABELS_BY_TEMPLATE));
|
|
233
|
+
|
|
192
234
|
// The idea's five sections, IN FRONT of the voter — the entire reason voting
|
|
193
235
|
// happens in the hall rather than on a reaction. Rendered in template order.
|
|
194
236
|
//
|
|
@@ -202,12 +244,20 @@
|
|
|
202
244
|
function ideaSectionsHtml(item) {
|
|
203
245
|
const fv = item.subject && item.subject.field_values;
|
|
204
246
|
if (!fv) return '';
|
|
205
|
-
const
|
|
247
|
+
const labels = labelsOf(item);
|
|
248
|
+
// The item's own vintage first, in its declared order — then anything the
|
|
249
|
+
// author wrote that this page has no label for. That tail is normally empty
|
|
250
|
+
// and exists so a vintage shipped after this page cannot make an author's
|
|
251
|
+
// answers INVISIBLE to the board being asked to ratify them: an unlabelled
|
|
252
|
+
// section reads as its key, which is ugly and honest, where dropping it is
|
|
253
|
+
// neither. (The vote FORM deliberately does not do this — see voteFormHtml.)
|
|
254
|
+
const extra = Object.keys(fv).filter((k) => !(k in labels));
|
|
255
|
+
const rows = Object.keys(labels).concat(extra).map((key) => {
|
|
206
256
|
// An unanswered section is dropped rather than rendered as a bare heading:
|
|
207
257
|
// a graded Full Idea has all five, and a blank one is only noise.
|
|
208
258
|
const body = String(fv[key] ?? '').trim();
|
|
209
259
|
if (!body) return '';
|
|
210
|
-
return `<h4 class="ov-prose__l">${escapeHtml(
|
|
260
|
+
return `<h4 class="ov-prose__l">${escapeHtml(labels[key] || key)}</h4>
|
|
211
261
|
<p class="ov-prose__p gov-idea__body">${escapeHtml(body)}</p>`;
|
|
212
262
|
}).join('');
|
|
213
263
|
return rows
|
|
@@ -268,8 +318,15 @@
|
|
|
268
318
|
// discovered by clicking.
|
|
269
319
|
const isOwnIdea = item.subject_type === 'full_idea'
|
|
270
320
|
&& item.subject && String(item.subject.author_id) === String(viewerId);
|
|
271
|
-
|
|
272
|
-
|
|
321
|
+
// THE PICKER OFFERS THE ITEM'S OWN VINTAGE, AND ONLY KEYS THE SERVER ACCEPTS.
|
|
322
|
+
// Unlike the prose above it does NOT fall through to whatever the author
|
|
323
|
+
// wrote: government_003's CHECK (widened by government_017) is a closed
|
|
324
|
+
// vocabulary, so offering a section outside it would render a radio whose
|
|
325
|
+
// only outcome is a refused vote. A vintage this page cannot resolve keeps
|
|
326
|
+
// base-v1's five — see templateOf.
|
|
327
|
+
const labels = labelsOf(item);
|
|
328
|
+
const sections = Object.keys(labels).map((key) => `
|
|
329
|
+
<label class="gov-vote__section"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(labels[key])}</span></label>`).join('');
|
|
273
330
|
const ownNote = isOwnIdea
|
|
274
331
|
? `<p class="gov-grid-note">Your own idea. You may vote, but it can’t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
|
|
275
332
|
: '';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- ideas_008_template_default_v2.sql — a NEW Full Idea is written against
|
|
2
|
+
-- `base-v2` (task 1004085 / BV2.R06, goal 1000095; spec decision 10 in
|
|
3
|
+
-- docs/specs/bongos-v2-ideator-surface.md).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS DOES NOT DO: it does not touch a single stored row. `template_key`
|
|
6
|
+
-- has been NOT NULL with a value on every row since ideas_001, and every writer
|
|
7
|
+
-- passes the key explicitly, so there is nothing to backfill and nothing to
|
|
8
|
+
-- reinterpret. The hundreds of ideas already filed keep saying 'base-v1' and keep
|
|
9
|
+
-- being rendered and scored against the questions they were actually asked —
|
|
10
|
+
-- which is the entire promise of versioning the template rather than editing it.
|
|
11
|
+
-- A backfill here would be the bug, not the missing half: `completeness_score`
|
|
12
|
+
-- feeds the ideator credit lane (R147), so re-scoring old thoughts against new
|
|
13
|
+
-- questions would quietly change what work already paid.
|
|
14
|
+
--
|
|
15
|
+
-- WHY MOVE THE COLUMN DEFAULT AT ALL, then. Belt and braces. The default is
|
|
16
|
+
-- unreachable in normal operation — modules/ideas/grades.js always names the key,
|
|
17
|
+
-- taking it from projection.js's DEFAULT_TEMPLATE_KEY, which this migration
|
|
18
|
+
-- mirrors. But a column default is a STATEMENT about what a row without an
|
|
19
|
+
-- opinion is, and leaving it at 'base-v1' would leave that statement false and
|
|
20
|
+
-- waiting: the first writer that ever omits the column would silently mint a new
|
|
21
|
+
-- idea wearing a retired vintage, validate its answers against the wrong five
|
|
22
|
+
-- questions, and score it against the wrong floors. Two statements of one fact
|
|
23
|
+
-- are only safe while they agree.
|
|
24
|
+
--
|
|
25
|
+
-- ALTER … SET DEFAULT is metadata only: no table rewrite, no lock held on the
|
|
26
|
+
-- rows, safe on a live instance of any size.
|
|
27
|
+
--
|
|
28
|
+
-- Additive and forward-safe. There is no down-migration, and the way back is not
|
|
29
|
+
-- this file run in reverse: 'base-v1' stays registered in modules/ideas/
|
|
30
|
+
-- templates.js forever, so reverting the default is a one-line code change with
|
|
31
|
+
-- no data consequence either way.
|
|
32
|
+
|
|
33
|
+
BEGIN;
|
|
34
|
+
|
|
35
|
+
ALTER TABLE ideas_grades ALTER COLUMN template_key SET DEFAULT 'base-v2';
|
|
36
|
+
|
|
37
|
+
COMMIT;
|
|
@@ -57,9 +57,22 @@ const VALID_GRADES = ['quick', 'spark', 'full'];
|
|
|
57
57
|
|
|
58
58
|
// Which template `field_values` was written against. Stored per row so a project
|
|
59
59
|
// that later defines its own shape (R156) does not retro-reinterpret ideas
|
|
60
|
-
// written against the base one.
|
|
61
|
-
// this module only records which template a row used.
|
|
62
|
-
|
|
60
|
+
// written against the base one. `templates.js` declares what each key actually
|
|
61
|
+
// contains — this module only records which template a row used.
|
|
62
|
+
//
|
|
63
|
+
// THIS IS THE DEFAULT FOR A NEW FILING ONLY, NEVER A READING OF A STORED ROW.
|
|
64
|
+
// It moved 'base-v1' -> 'base-v2' in task 1004085 (BV2.R06, spec decision 10:
|
|
65
|
+
// the five questions are replaced and versioned), and that move is safe for
|
|
66
|
+
// exactly one reason — every graded row carries its own `template_key`, so the
|
|
67
|
+
// hundreds already stored keep resolving to the v1 declaration and keep being
|
|
68
|
+
// scored against the questions they were put. Anything that reaches for this
|
|
69
|
+
// constant while holding a row is a bug: read `row.template_key`.
|
|
70
|
+
//
|
|
71
|
+
// `ideas_008_template_default_v2.sql` moves the COLUMN default to match. The two
|
|
72
|
+
// are belt and braces — every writer passes the key explicitly — but a column
|
|
73
|
+
// default that still said 'base-v1' would be a false statement about what a new
|
|
74
|
+
// idea is, waiting for the first writer that forgets.
|
|
75
|
+
const DEFAULT_TEMPLATE_KEY = 'base-v2';
|
|
63
76
|
const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
|
|
64
77
|
const TEMPLATE_KEY_MAX = 64;
|
|
65
78
|
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// modules/ideas/templates.js — the Full Idea template declarations (task 1002917,
|
|
2
2
|
// BV1.R141, goal 1000062).
|
|
3
3
|
//
|
|
4
|
-
// THIS FILE IS WHAT
|
|
5
|
-
// jsonb against a `template_key` and deliberately declares nothing
|
|
6
|
-
// its header says "R141 declares what 'base-v1' means". This is that
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// THIS FILE IS WHAT EVERY `template_key` MEANS. ideas_001_grades.sql stores
|
|
5
|
+
// `field_values` jsonb against a `template_key` and deliberately declares nothing
|
|
6
|
+
// about shape — its header says "R141 declares what 'base-v1' means". This is that
|
|
7
|
+
// declaration, and since task 1004085 (BV2.R06) there are TWO of them: `base-v2`,
|
|
8
|
+
// the questions a Full Idea is asked today, and `base-v1` beside it, the questions
|
|
9
|
+
// the ideas already in the table were actually asked. Each is the owner's own
|
|
10
|
+
// wording, in the order they display.
|
|
11
|
+
//
|
|
12
|
+
// A VINTAGE IS ADDITIVE AND FROZEN. A new question set is a new key in the
|
|
13
|
+
// registry and a new default; the old declaration is never edited and never
|
|
14
|
+
// removed, because the scorer walks whichever declaration a stored row names and
|
|
15
|
+
// `completeness_score` feeds credits. Re-asking is free; re-scoring is not.
|
|
9
16
|
//
|
|
10
17
|
// THE TEMPLATE IS DATA, READ BY BOTH VALIDATION AND SCORING. Capture (R143)
|
|
11
18
|
// validates a submitted `field_values` against a template with
|
|
@@ -43,7 +50,7 @@ const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
|
|
|
43
50
|
// answered, not padded past" — declared here as data so the scorer stays
|
|
44
51
|
// template-agnostic; validation deliberately does NOT enforce it (a thin answer
|
|
45
52
|
// is a scoring problem, not a capture error — scoring never blocks capture).
|
|
46
|
-
const
|
|
53
|
+
const BASE_V1_TEMPLATE = Object.freeze({
|
|
47
54
|
key: 'base-v1',
|
|
48
55
|
label: 'Full Idea',
|
|
49
56
|
fields: Object.freeze([
|
|
@@ -55,21 +62,81 @@ const BASE_TEMPLATE = Object.freeze({
|
|
|
55
62
|
]),
|
|
56
63
|
});
|
|
57
64
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
65
|
+
// THE QUESTIONS A FULL IDEA IS ASKED TODAY (task 1004085 / BV2.R06, goal 1000095;
|
|
66
|
+
// spec decision 10, `docs/specs/bongos-v2-ideator-surface.md`). The owner's own
|
|
67
|
+
// set, in the owner's own order.
|
|
68
|
+
//
|
|
69
|
+
// WHY A SECOND TEMPLATE RATHER THAN AN EDIT TO THE FIRST. Every graded row stores
|
|
70
|
+
// the `template_key` it was written against, and the scorer walks the declaration
|
|
71
|
+
// that key resolves to — so editing `base-v1` in place would re-score hundreds of
|
|
72
|
+
// already-filed ideas against questions nobody asked them, and `completeness_score`
|
|
73
|
+
// feeds the ideator credit lane (R147). A thought is scored on the questions it
|
|
74
|
+
// was actually put. `base-v1` above is therefore FROZEN HISTORY: it is never
|
|
75
|
+
// edited again, only read.
|
|
76
|
+
//
|
|
77
|
+
// TWO KEYS ARE REUSED DELIBERATELY. `why_had` survives outright ("what made you
|
|
78
|
+
// have the idea") and `fit` survives roughly (the long-term justification moved
|
|
79
|
+
// from "how does it fit" to "how is it justified long term"). Reusing the key is
|
|
80
|
+
// what makes this a registry entry instead of a data migration; `template_key` is
|
|
81
|
+
// what keeps the two readings apart, so a v1 row's `fit` is still rendered under
|
|
82
|
+
// the v1 label. `purpose`, `big_picture` and `why_matters` are NOT carried over —
|
|
83
|
+
// they belong to v1 and stay there.
|
|
84
|
+
//
|
|
85
|
+
// THE FLOORS ARE FLAT 40, MATCHING v1, AND THAT IS TEMPORARY. The new questions
|
|
86
|
+
// are not interchangeable ("scope it as best as you can" deserves more room than
|
|
87
|
+
// "what made you have the idea"), but the score feeds credits, so choosing the
|
|
88
|
+
// numbers is its own deliberate act — task 1004091 (BV2.R12) owns it. Shipping a
|
|
89
|
+
// guess here would have been an unreviewed change to what a thought pays.
|
|
90
|
+
const BASE_V2_TEMPLATE = Object.freeze({
|
|
91
|
+
key: 'base-v2',
|
|
92
|
+
label: 'Full Idea',
|
|
93
|
+
fields: Object.freeze([
|
|
94
|
+
Object.freeze({ key: 'why_had', label: 'What made you have the idea', required: true, substanceMin: 40 }),
|
|
95
|
+
Object.freeze({ key: 'scope', label: 'Scope it as best as you can here', required: true, substanceMin: 40 }),
|
|
96
|
+
Object.freeze({ key: 'build_context', label: 'What should an engineer or artist know prior to building', required: true, substanceMin: 40 }),
|
|
97
|
+
Object.freeze({ key: 'fit', label: 'How is this long term justified in the project', required: true, substanceMin: 40 }),
|
|
98
|
+
Object.freeze({ key: 'success', label: 'What does a successful implementation of the idea look like', required: true, substanceMin: 40 }),
|
|
99
|
+
]),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The template a NEW Full Idea is written against — an alias, not a sixth
|
|
103
|
+
// declaration. Consumers that mean "the current questions" (scripts/gds/spark.js
|
|
104
|
+
// prints them; the tests build a full payload from them) read this, so the alias
|
|
105
|
+
// is what makes flipping the default a one-line change here rather than a sweep.
|
|
106
|
+
// Anything that means a SPECIFIC vintage names `BASE_V1_TEMPLATE` /
|
|
107
|
+
// `BASE_V2_TEMPLATE` instead.
|
|
108
|
+
const BASE_TEMPLATE = BASE_V2_TEMPLATE;
|
|
109
|
+
|
|
110
|
+
// The built-in registry. Two entries: what ideas are asked now, and what they
|
|
111
|
+
// were asked before. Frozen so a consumer can never mutate a declaration into a
|
|
112
|
+
// de-facto fork — a different shape is a different registry (the seam above).
|
|
113
|
+
// AN ENTRY IS NEVER REMOVED FROM HERE while rows still name it: `templateFor`
|
|
114
|
+
// returns null for an unknown key, and a null template is how a stored idea
|
|
115
|
+
// loses its questions and its score at once.
|
|
61
116
|
const BUILTIN_TEMPLATES = Object.freeze({
|
|
62
|
-
[
|
|
117
|
+
[BASE_V1_TEMPLATE.key]: BASE_V1_TEMPLATE,
|
|
118
|
+
[BASE_V2_TEMPLATE.key]: BASE_V2_TEMPLATE,
|
|
63
119
|
});
|
|
64
120
|
|
|
65
121
|
// Sanity pin, not a runtime branch: the store's default key and this file's
|
|
66
|
-
//
|
|
122
|
+
// current declaration are two statements of the same fact, and requiring them to
|
|
67
123
|
// agree at load time turns a rename-drift into a boot failure instead of a
|
|
68
124
|
// silent "every new row validates against a template that does not exist".
|
|
69
125
|
if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
|
|
70
126
|
throw new Error(`templates: BASE_TEMPLATE.key (${BASE_TEMPLATE.key}) !== inbox DEFAULT_TEMPLATE_KEY (${DEFAULT_TEMPLATE_KEY})`);
|
|
71
127
|
}
|
|
72
128
|
|
|
129
|
+
// The second half of the same pin, and the one this task exists to hold: every
|
|
130
|
+
// vintage a row can still carry must stay resolvable. The default moving forward
|
|
131
|
+
// is a feature; a retired template silently dropping out of the registry is the
|
|
132
|
+
// failure mode — it would leave already-filed ideas with no questions to render
|
|
133
|
+
// and no floors to score against.
|
|
134
|
+
for (const t of [BASE_V1_TEMPLATE, BASE_V2_TEMPLATE]) {
|
|
135
|
+
if (!Object.hasOwn(BUILTIN_TEMPLATES, t.key)) {
|
|
136
|
+
throw new Error(`templates: ${t.key} is declared but not registered — an already-filed idea would lose its questions`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
73
140
|
// Resolve a template by key. Null for an unknown key — the caller decides
|
|
74
141
|
// whether that is a 400 (capture) or a render-as-raw fallback (display).
|
|
75
142
|
function templateFor(key, registry = BUILTIN_TEMPLATES) {
|
|
@@ -144,6 +211,8 @@ function validateFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY,
|
|
|
144
211
|
|
|
145
212
|
module.exports = {
|
|
146
213
|
BASE_TEMPLATE,
|
|
214
|
+
BASE_V1_TEMPLATE,
|
|
215
|
+
BASE_V2_TEMPLATE,
|
|
147
216
|
BUILTIN_TEMPLATES,
|
|
148
217
|
templateFor,
|
|
149
218
|
fieldOrder,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.934",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.934",
|
|
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.19.
|
|
3
|
+
"version": "1.19.934",
|
|
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/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.934'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -138,8 +138,15 @@ test('the self-explaining copy is DELETED, not reworded', () => {
|
|
|
138
138
|
|
|
139
139
|
test('the section a voter faults is shown by NAME, in the ballot and the picker', () => {
|
|
140
140
|
const ballot = BOARD.slice(BOARD.indexOf('function ballotHtml'), BOARD.indexOf('// ---- the vote form'));
|
|
141
|
-
|
|
141
|
+
// Since task 1004085 the label comes from the item's OWN vintage — `why_had`
|
|
142
|
+
// and `fit` exist in both templates asking different questions, so a single
|
|
143
|
+
// flat map would show the reader the wrong sentence. `ANY_SECTION_LABEL` is
|
|
144
|
+
// the decode-only fallback behind it, for a section whose vintage this page
|
|
145
|
+
// cannot resolve; the one thing that must never appear is the bare key.
|
|
146
|
+
assert.match(ballot, /labelsOf\(item\)\[v\.section_key\]/,
|
|
142
147
|
'`why_matters` is a jsonb key; the objector picked a named section and the reader should see that name');
|
|
148
|
+
assert.match(ballot, /ANY_SECTION_LABEL\[v\.section_key\]/,
|
|
149
|
+
'and an older vintage still decodes to words rather than to its key');
|
|
143
150
|
});
|
|
144
151
|
|
|
145
152
|
test('the objection still carries a reason AND one section (ADR 0175 §8)', () => {
|
|
@@ -147,8 +154,10 @@ test('the objection still carries a reason AND one section (ADR 0175 §8)', () =
|
|
|
147
154
|
// hall instead of on a chat reaction.
|
|
148
155
|
const form = BOARD.slice(BOARD.indexOf('function voteFormHtml'), BOARD.indexOf('async function castVoteFromRoom'));
|
|
149
156
|
assert.match(form, /data-vote-reason/, 'the reason box survives');
|
|
150
|
-
assert.match(form, /gov-vote__sections/, 'so does the
|
|
151
|
-
assert.match(form, /Object\.keys\(
|
|
157
|
+
assert.match(form, /gov-vote__sections/, 'so does the section picker');
|
|
158
|
+
assert.match(form, /Object\.keys\(labels\)\.map/, 'with one radio per declared section');
|
|
159
|
+
assert.match(form, /const labels = labelsOf\(item\)/,
|
|
160
|
+
'and the sections offered are the ones the subject was actually written against');
|
|
152
161
|
});
|
|
153
162
|
|
|
154
163
|
test('each room frames itself in one line, not a paragraph', () => {
|
|
@@ -29,19 +29,44 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
29
29
|
const GOV = path.join(ROOT, 'modules', 'government');
|
|
30
30
|
const board = require(path.join(GOV, 'board.js'));
|
|
31
31
|
const db = require(path.join(GOV, 'db.js'));
|
|
32
|
-
const { fieldOrder, templateFor } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
32
|
+
const { fieldOrder, templateFor, BUILTIN_TEMPLATES } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
33
33
|
|
|
34
34
|
const { FULL_IDEA_SECTION_KEYS, VOTE_KARMA_DELTA, AUTHOR_RATIFY_KARMA_DELTA, awardAuthorRatifyKarma, validateVote, castVote } = board;
|
|
35
35
|
|
|
36
36
|
// ── invariant 1: one list, three places ─────────────────────────────────────
|
|
37
37
|
|
|
38
|
+
// THE LIST IS THE UNION OF EVERY VINTAGE, since task 1004085 (BV2.R06) versioned
|
|
39
|
+
// the Full Idea questions. It was `base-v1`'s five, and pinning it to one
|
|
40
|
+
// template again would re-open the defect that task closed: an idea filed under
|
|
41
|
+
// a newer vintage reaches the board, and every section a voter could object to
|
|
42
|
+
// is refused `objection_needs_section` — leaving yes as the only castable vote.
|
|
43
|
+
const ALL_TEMPLATE_FIELD_KEYS = [...new Set(
|
|
44
|
+
Object.keys(BUILTIN_TEMPLATES).flatMap((key) => fieldOrder(templateFor(key))),
|
|
45
|
+
)];
|
|
46
|
+
|
|
38
47
|
test('the objection section keys ARE the Full Idea template fields (no second list that drifts)', () => {
|
|
39
|
-
assert.deepEqual([...FULL_IDEA_SECTION_KEYS],
|
|
40
|
-
'board.js must flag against the
|
|
48
|
+
assert.deepEqual([...FULL_IDEA_SECTION_KEYS].sort(), [...ALL_TEMPLATE_FIELD_KEYS].sort(),
|
|
49
|
+
'board.js must flag against exactly the sections the templates declare — every vintage, no extras');
|
|
50
|
+
|
|
51
|
+
// The CHECK that is actually in force is government_003's as widened by
|
|
52
|
+
// government_017; a key is legal if the LATEST statement of the constraint
|
|
53
|
+
// carries it, so that is the file this reads.
|
|
41
54
|
const migration = fs.readFileSync(
|
|
42
|
-
path.join(GOV, 'migrations', '
|
|
55
|
+
path.join(GOV, 'migrations', 'government_017_board_section_keys_v2.sql'), 'utf8');
|
|
43
56
|
for (const key of FULL_IDEA_SECTION_KEYS) {
|
|
44
|
-
assert.ok(migration.includes(`'${key}'`), `the
|
|
57
|
+
assert.ok(migration.includes(`'${key}'`), `the in-force CHECK must carry '${key}'`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// NARROWING IS THE FAILURE MODE, so it gets its own assertion rather than
|
|
61
|
+
// riding on the set equality above: objections already cast name government_003's
|
|
62
|
+
// keys, and dropping one would orphan them and make the CHECK refuse rows that
|
|
63
|
+
// are already in the table.
|
|
64
|
+
const original = fs.readFileSync(
|
|
65
|
+
path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
|
|
66
|
+
for (const key of fieldOrder(templateFor('base-v1'))) {
|
|
67
|
+
assert.ok(original.includes(`'${key}'`), `sanity: government_003 declared '${key}'`);
|
|
68
|
+
assert.ok(FULL_IDEA_SECTION_KEYS.includes(key), `'${key}' must never leave the list`);
|
|
69
|
+
assert.ok(migration.includes(`'${key}'`), `'${key}' must never leave the CHECK`);
|
|
45
70
|
}
|
|
46
71
|
});
|
|
47
72
|
|
|
@@ -64,6 +89,29 @@ test('a yes carries nothing; a no carries a reason AND exactly one section', ()
|
|
|
64
89
|
}
|
|
65
90
|
});
|
|
66
91
|
|
|
92
|
+
test('an objection can name a section of EITHER vintage — BV2.R06\'s actual defect', () => {
|
|
93
|
+
// The set-equality test above compares two lists. This one drives the real
|
|
94
|
+
// validator, one section at a time, because the list being right and the
|
|
95
|
+
// OBJECTION being accepted are different claims — and it is the second one
|
|
96
|
+
// that decides whether a new-vintage idea can be returned at all. Before task
|
|
97
|
+
// 1004085 every key on the base-v2 side of this loop answered
|
|
98
|
+
// `objection_needs_section`, leaving yes as the only castable vote on any idea
|
|
99
|
+
// filed after the questions changed.
|
|
100
|
+
for (const templateKey of Object.keys(BUILTIN_TEMPLATES)) {
|
|
101
|
+
for (const sectionKey of fieldOrder(templateFor(templateKey))) {
|
|
102
|
+
const v = validateVote({ direction: 'no', reasonMd: 'this section needs work', sectionKey });
|
|
103
|
+
assert.equal(v.ok, true, `${templateKey}'s '${sectionKey}' must be objectable`);
|
|
104
|
+
assert.equal(v.sectionKey, sectionKey);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// And the list is still CLOSED — widening it is not opening it. A plausible
|
|
108
|
+
// key that no template declares is refused exactly as 'vibes' is.
|
|
109
|
+
for (const notASection of ['scope_notes', 'why', 'build', 'purpose_v2']) {
|
|
110
|
+
assert.equal(validateVote({ direction: 'no', reasonMd: 'x', sectionKey: notASection }).reason,
|
|
111
|
+
'objection_needs_section', `'${notASection}' is not a declared section`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
67
115
|
// ── castVote against a stubbed db (membership logic REAL) ───────────────────
|
|
68
116
|
|
|
69
117
|
const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
@@ -21,21 +21,41 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
21
21
|
// with the room onto its own page. The name stays `CLIENT` because that is what
|
|
22
22
|
// each assertion below reads — this file has exactly one source.
|
|
23
23
|
const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
|
|
24
|
-
const {
|
|
24
|
+
const { BASE_V1_TEMPLATE, BASE_V2_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
|
|
25
25
|
const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
|
|
26
26
|
|
|
27
|
-
test('the
|
|
28
|
-
|
|
27
|
+
test('the section labels ARE the template declarations — every vintage, key and label, in order', () => {
|
|
28
|
+
// Since task 1004085 (BV2.R06) there are TWO declarations, and the page holds
|
|
29
|
+
// one map per vintage. The order of the maps in the source is the order of the
|
|
30
|
+
// vintages here, so the flattened pair list is compared against the flattened
|
|
31
|
+
// declarations — which is what catches a label edited on one side only.
|
|
32
|
+
const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS_BY_TEMPLATE'), CLIENT.indexOf('function ideaSectionsHtml'));
|
|
29
33
|
const declared = [...block.matchAll(/(\w+): '([^']+)'/g)].map((m) => [m[1], m[2]]);
|
|
30
|
-
const template =
|
|
34
|
+
const template = [...BASE_V1_TEMPLATE.fields, ...BASE_V2_TEMPLATE.fields].map((f) => [f.key, f.label]);
|
|
31
35
|
assert.deepEqual(declared, template,
|
|
32
|
-
'the voter reads the same
|
|
36
|
+
'the voter reads the same section names the author wrote under — held equal here because browser code cannot import the server module');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('the labels are keyed BY VINTAGE, never flattened into one map', () => {
|
|
40
|
+
// `why_had` and `fit` exist in both vintages carrying DIFFERENT questions, so a
|
|
41
|
+
// single merged key->label map would show one author the question a different
|
|
42
|
+
// author was asked. This is the assertion that forbids the tidy-up.
|
|
43
|
+
assert.match(CLIENT, /SECTION_LABELS_BY_TEMPLATE\s*=\s*\{[^]*'base-v1'\s*:[^]*'base-v2'\s*:/,
|
|
44
|
+
'one map per template key');
|
|
45
|
+
assert.match(CLIENT, /item\.subject\.template_key/,
|
|
46
|
+
'the vintage comes from the SUBJECT — never a page-level default');
|
|
47
|
+
// The shared keys really are shared and really do differ, so the test above is
|
|
48
|
+
// guarding something rather than restating a shape.
|
|
49
|
+
const v1 = new Map(BASE_V1_TEMPLATE.fields.map((f) => [f.key, f.label]));
|
|
50
|
+
for (const f of BASE_V2_TEMPLATE.fields) {
|
|
51
|
+
if (v1.has(f.key)) assert.notEqual(v1.get(f.key), f.label, `${f.key} asks differently per vintage`);
|
|
52
|
+
}
|
|
33
53
|
});
|
|
34
54
|
|
|
35
55
|
test('the idea is in front of the voter: sections render from the subject, in template order', () => {
|
|
36
56
|
assert.match(CLIENT, /item\.subject\.field_values|subject && item\.subject\.field_values/,
|
|
37
57
|
'the sections come from the board view payload, never a second fetch');
|
|
38
|
-
assert.match(CLIENT, /Object\.keys\(
|
|
58
|
+
assert.match(CLIENT, /Object\.keys\(labels\)/,
|
|
39
59
|
'rendered in the declared order — jsonb key order is never trusted');
|
|
40
60
|
const card = CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
|
|
41
61
|
assert.match(card, /ideaSectionsHtml\(item\)/, 'the sections sit ON the item card, above the ballot');
|
|
@@ -41,6 +41,7 @@ const NON_GRANT_MIGRATIONS = new Set([
|
|
|
41
41
|
'government_006_returned_items_index.sql', // R13 returned-item lookup index (task 1003026)
|
|
42
42
|
'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
|
|
43
43
|
'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
|
|
44
|
+
'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
|
|
44
45
|
]);
|
|
45
46
|
|
|
46
47
|
// Permission keys RENAMED after the grant migrations that first granted them.
|
package/tests/helpers.mjs
CHANGED
|
@@ -976,3 +976,26 @@ export function bootSkyPage({
|
|
|
976
976
|
},
|
|
977
977
|
};
|
|
978
978
|
}
|
|
979
|
+
|
|
980
|
+
// namesFieldKey — does this source USE `key` as a Full Idea field key, as
|
|
981
|
+
// opposed to merely containing the word? (task 1004085 / BV2.R06.)
|
|
982
|
+
//
|
|
983
|
+
// Two suites assert that a surface carries no copy of the template — the hall
|
|
984
|
+
// composer (`tests/idea_hall_composer.mjs`, R152) and the ideate-full skill
|
|
985
|
+
// (`tests/idea_ideate_full.mjs`). Both did it with `src.includes(key)`, which
|
|
986
|
+
// was sound while every key was a compound like `big_picture` and broke the day
|
|
987
|
+
// base-v2 introduced `scope`: ideas.js already said "scoped per version" and
|
|
988
|
+
// "the protected-scope wall", and the skill prose says it too. Both reported a
|
|
989
|
+
// hard-coded form that does not exist.
|
|
990
|
+
//
|
|
991
|
+
// The three shapes below are how a surface could really name a field — a string
|
|
992
|
+
// literal (or a markdown backtick span, which is the same characters), a
|
|
993
|
+
// property, or an unquoted HTML attribute value. Everything else is English.
|
|
994
|
+
// It lives here rather than in either suite because two copies of a predicate
|
|
995
|
+
// this fiddly are two chances for one to be loosened alone.
|
|
996
|
+
export function namesFieldKey(src, key) {
|
|
997
|
+
const k = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
998
|
+
return new RegExp(`(['"\`])${k}\\1`).test(src)
|
|
999
|
+
|| new RegExp(`\\.${k}\\b`).test(src)
|
|
1000
|
+
|| new RegExp(`=\\s*${k}\\b`).test(src);
|
|
1001
|
+
}
|