@bongos/core 1.19.250 → 1.19.252
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 +100 -30
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0179-board-membership-predicate-grammar.md +113 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -0
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +1432 -0
- package/docs/copy-registry.json +13311 -0
- package/modules/copy-desk/CLAUDE.md +15 -0
- package/modules/copy-desk/flags.js +245 -0
- package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
- package/modules/copy-desk/module.json +11 -0
- package/modules/copy-desk/registry.js +317 -0
- package/modules/copy-desk/routes/copy-desk.js +272 -0
- package/modules/copy-desk/tests/copy_flags.mjs +246 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
- package/modules/copy-desk/tests/copy_queue.mjs +257 -0
- package/modules/government/board-membership.js +20 -20
- package/modules/government/board.js +57 -3
- package/modules/government/config.js +14 -8
- package/modules/government/db.js +18 -7
- package/modules/government/membership-predicate.js +115 -0
- package/modules/government/routes/government.js +19 -0
- package/modules/hall-ui/public/government.js +54 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +9 -0
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +5 -1
- package/tests/government_board_amendment.mjs +87 -1
- package/tests/government_board_membership.mjs +101 -10
- package/tests/government_board_membership_db.mjs +150 -0
- package/tests/government_board_vote.mjs +5 -3
- package/tests/government_constitution_view.mjs +53 -0
- package/tests/publish_manifest.mjs +82 -0
- package/tests/rank_tier_single_source.mjs +9 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_flags.mjs — the pure validators behind the flag
|
|
2
|
+
// write path (task 1003113 / R02 of goal 1000074).
|
|
3
|
+
//
|
|
4
|
+
// The criterion these pin: A FLAG CARRIES A JUDGEMENT, AND POINTS AT SOMETHING
|
|
5
|
+
// REAL. Everything below is one of those two halves — the reason floor (and the
|
|
6
|
+
// closing-note floor that mirrors it), or the registry lookups that stop a flag
|
|
7
|
+
// being filed against a surface or string that does not exist.
|
|
8
|
+
//
|
|
9
|
+
// The snapshot tests are the load-bearing ones: the snapshot's whole value is
|
|
10
|
+
// that it can be trusted a month later, which is only true if it comes from the
|
|
11
|
+
// registry and never from the request body.
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const flags = require('../flags.js');
|
|
18
|
+
const registry = require('../registry.js');
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
async function test(name, fn) {
|
|
23
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
24
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A miniature registry in the shape copy-inventory.js emits, so these tests
|
|
28
|
+
// never depend on what the real tree happens to say today.
|
|
29
|
+
const FAKE = {
|
|
30
|
+
schema: 1,
|
|
31
|
+
generator: 'scripts/gds/copy-inventory.js',
|
|
32
|
+
counts: { strings: 3, unique_ids: 2, certain: 3, heuristic: 0, duplicate_ids: 1 },
|
|
33
|
+
surfaces: {
|
|
34
|
+
'builders-hall': { at: 'modules/hall-ui/public', files: 2, strings: 2 },
|
|
35
|
+
landing: { at: 'public-landing', files: 1, strings: 1 },
|
|
36
|
+
},
|
|
37
|
+
duplicates: [
|
|
38
|
+
{ id: 'dup1', surface: 'builders-hall', text: 'Could not save.', occurrences: 2,
|
|
39
|
+
at: ['modules/hall-ui/public/a.js:10', 'modules/hall-ui/public/b.js:20'] },
|
|
40
|
+
],
|
|
41
|
+
entries: [
|
|
42
|
+
{ id: 'dup1', surface: 'builders-hall', text: 'Could not save.', file: 'modules/hall-ui/public/a.js', line: 10, origin: 'js-notice', confidence: 'certain' },
|
|
43
|
+
{ id: 'dup1', surface: 'builders-hall', text: 'Could not save.', file: 'modules/hall-ui/public/b.js', line: 20, origin: 'js-notice', confidence: 'certain' },
|
|
44
|
+
{ id: 'solo', surface: 'landing', text: 'Enter the future.', file: 'public-landing/index.html', line: 4, origin: 'html-text', confidence: 'certain' },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const INDEX = registry.indexRegistry(FAKE);
|
|
48
|
+
const ok = (body) => flags.normalizeFlagPayload(body, INDEX, registry);
|
|
49
|
+
|
|
50
|
+
const GOOD_REASON = 'Reads like a stack trace, not a sentence a person would say.';
|
|
51
|
+
|
|
52
|
+
console.log('copy-desk: flag payload validation');
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// The reason floor — the rule the whole queue's usefulness rests on.
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
await test('a flag with no reason is refused', () => {
|
|
59
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo' });
|
|
60
|
+
assert.equal(r.ok, false);
|
|
61
|
+
assert.equal(r.code, 'reason_too_thin');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await test('a one-word reason is refused', () => {
|
|
65
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: 'bad' });
|
|
66
|
+
assert.equal(r.ok, false);
|
|
67
|
+
assert.equal(r.code, 'reason_too_thin');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('the floor measures content, not whitespace', () => {
|
|
71
|
+
// 15 characters, 3 of content. A spacebar must not clear a content floor.
|
|
72
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: ' f i x ' });
|
|
73
|
+
assert.equal(r.ok, false);
|
|
74
|
+
assert.equal(r.code, 'reason_too_thin');
|
|
75
|
+
assert.equal(r.detail.got, 5); // 'f i x' after collapsing
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await test('the refusal explains WHY, not just that a rule fired', () => {
|
|
79
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: 'no' });
|
|
80
|
+
assert.equal(typeof r.detail.why, 'string');
|
|
81
|
+
assert.ok(r.detail.why.length > 40, 'the "why" must be a sentence, not a code');
|
|
82
|
+
assert.equal(r.detail.min_chars, flags.MIN_REASON_CHARS);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await test('a real reason passes and is stored tidied', () => {
|
|
86
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: ` ${GOOD_REASON}\n\n` });
|
|
87
|
+
assert.equal(r.ok, true);
|
|
88
|
+
assert.equal(r.value.reason, GOOD_REASON);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Pointing at something real.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
await test('an unknown surface is refused, and the refusal lists the known ones', () => {
|
|
96
|
+
const r = ok({ scope: 'surface', surface: 'not-a-surface', reason: GOOD_REASON });
|
|
97
|
+
assert.equal(r.ok, false);
|
|
98
|
+
assert.equal(r.code, 'unknown_surface');
|
|
99
|
+
assert.deepEqual([...r.detail.known].sort(), ['builders-hall', 'landing']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('an unknown string id is refused', () => {
|
|
103
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'nope', reason: GOOD_REASON });
|
|
104
|
+
assert.equal(r.ok, false);
|
|
105
|
+
assert.equal(r.code, 'unknown_string_id');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test("scope 'string' with no string_id is refused", () => {
|
|
109
|
+
const r = ok({ scope: 'string', surface: 'landing', reason: GOOD_REASON });
|
|
110
|
+
assert.equal(r.ok, false);
|
|
111
|
+
assert.equal(r.code, 'string_id_required');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await test("scope 'surface' stores no string_id and no snapshot", () => {
|
|
115
|
+
const r = ok({ scope: 'surface', surface: 'landing', reason: GOOD_REASON });
|
|
116
|
+
assert.equal(r.ok, true);
|
|
117
|
+
assert.equal(r.value.string_id, null);
|
|
118
|
+
assert.equal(r.value.text_at_flag, null);
|
|
119
|
+
// The DB constraint copy_desk_flags_target_chk enforces the same coupling; this
|
|
120
|
+
// is the half that produces a readable error instead of a 500.
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('a body naming a different surface than the registry is REFUSED, not silently corrected', () => {
|
|
124
|
+
// Silently filing it under the registry's surface would send the flag somewhere
|
|
125
|
+
// the reporter never chose, and they would never find out.
|
|
126
|
+
const r = ok({ scope: 'string', surface: 'builders-hall', string_id: 'solo', reason: GOOD_REASON });
|
|
127
|
+
assert.equal(r.ok, false);
|
|
128
|
+
assert.equal(r.code, 'surface_mismatch');
|
|
129
|
+
assert.equal(r.detail.registry_surface, 'landing');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// The snapshot comes from the registry, never from the caller.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
await test('the snapshot is taken from the registry', () => {
|
|
137
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: GOOD_REASON });
|
|
138
|
+
assert.equal(r.ok, true);
|
|
139
|
+
assert.equal(r.value.text_at_flag, 'Enter the future.');
|
|
140
|
+
assert.equal(r.value.file_at_flag, 'public-landing/index.html');
|
|
141
|
+
assert.equal(r.value.line_at_flag, 4);
|
|
142
|
+
assert.equal(r.value.registry_schema, 1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('a caller-supplied snapshot is IGNORED', () => {
|
|
146
|
+
// The snapshot's entire value is that it is trustworthy later. A body that can
|
|
147
|
+
// set it turns it into a claim by whoever filed the flag.
|
|
148
|
+
const r = ok({
|
|
149
|
+
scope: 'string', surface: 'landing', string_id: 'solo', reason: GOOD_REASON,
|
|
150
|
+
text_at_flag: 'something the reporter made up',
|
|
151
|
+
file_at_flag: 'somewhere/else.js',
|
|
152
|
+
line_at_flag: 999,
|
|
153
|
+
});
|
|
154
|
+
assert.equal(r.ok, true);
|
|
155
|
+
assert.equal(r.value.text_at_flag, 'Enter the future.');
|
|
156
|
+
assert.equal(r.value.file_at_flag, 'public-landing/index.html');
|
|
157
|
+
assert.equal(r.value.line_at_flag, 4);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('a duplicated string snapshots its first occurrence, and the id resolves to all of them', () => {
|
|
161
|
+
const r = ok({ scope: 'string', surface: 'builders-hall', string_id: 'dup1', reason: GOOD_REASON });
|
|
162
|
+
assert.equal(r.ok, true);
|
|
163
|
+
assert.equal(r.value.file_at_flag, 'modules/hall-ui/public/a.js');
|
|
164
|
+
assert.equal(registry.occurrencesOf(INDEX, 'dup1').length, 2,
|
|
165
|
+
'one id maps to MANY entries — mapping id→entry would drop every duplicate but the first');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Kind + scope enums mirror the DB CHECKs.
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
await test('an unknown kind is refused with the allowed list', () => {
|
|
173
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: GOOD_REASON, kind: 'vibes' });
|
|
174
|
+
assert.equal(r.ok, false);
|
|
175
|
+
assert.equal(r.code, 'bad_kind');
|
|
176
|
+
assert.deepEqual(r.detail.allowed, flags.KINDS);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('kind defaults to other', () => {
|
|
180
|
+
const r = ok({ scope: 'string', surface: 'landing', string_id: 'solo', reason: GOOD_REASON });
|
|
181
|
+
assert.equal(r.value.kind, 'other');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('the JS enums match the migration CHECK constraints exactly', async () => {
|
|
185
|
+
// Two lists that drift is the failure mode this pins: the route would accept a
|
|
186
|
+
// value the DB then rejects as a 500.
|
|
187
|
+
const fs = await import('node:fs');
|
|
188
|
+
const url = await import('node:url');
|
|
189
|
+
const path = await import('node:path');
|
|
190
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
191
|
+
const sql = fs.readFileSync(path.join(here, '..', 'migrations', 'copy_desk_001_flags.sql'), 'utf8');
|
|
192
|
+
for (const [name, list] of [['kind', flags.KINDS], ['scope', flags.SCOPES], ['status', flags.STATUSES]]) {
|
|
193
|
+
const m = new RegExp(`CHECK \\(${name} IN \\(([^)]*)\\)\\)`).exec(sql);
|
|
194
|
+
assert.ok(m, `migration has no CHECK for ${name}`);
|
|
195
|
+
const inSql = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
|
|
196
|
+
assert.deepEqual(inSql.sort(), [...list].sort(), `${name} enum drifted between flags.js and the migration`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Closing a flag.
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
console.log('copy-desk: closing a flag');
|
|
205
|
+
|
|
206
|
+
await test('closing to a non-closing status is refused', () => {
|
|
207
|
+
const r = flags.normalizeResolution({ status: 'open', resolution_note: 'changed the wording' });
|
|
208
|
+
assert.equal(r.ok, false);
|
|
209
|
+
assert.equal(r.code, 'bad_status');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await test('closing with no note is refused — the symmetry with opening is the point', () => {
|
|
213
|
+
const r = flags.normalizeResolution({ status: 'dismissed' });
|
|
214
|
+
assert.equal(r.ok, false);
|
|
215
|
+
assert.equal(r.code, 'resolution_note_too_thin');
|
|
216
|
+
assert.ok(r.detail.why.length > 40);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('dismissed is a first-class close, not a soft delete', () => {
|
|
220
|
+
const r = flags.normalizeResolution({ status: 'dismissed', resolution_note: 'Deliberate house style; leaving as is.' });
|
|
221
|
+
assert.equal(r.ok, true);
|
|
222
|
+
assert.equal(r.value.status, 'dismissed');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await test('resolved may name the task that carried the fix', () => {
|
|
226
|
+
const r = flags.normalizeResolution({ status: 'resolved', resolution_note: 'Rewritten in the ship below.', resolved_task_id: 1003113 });
|
|
227
|
+
assert.equal(r.ok, true);
|
|
228
|
+
assert.equal(r.value.resolved_task_id, 1003113);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('resolved WITHOUT a task id is allowed, and records the absence', () => {
|
|
232
|
+
// A string can legitimately be fixed by a task filed before the flag existed.
|
|
233
|
+
// Refusing the close would leave a false open flag standing.
|
|
234
|
+
const r = flags.normalizeResolution({ status: 'resolved', resolution_note: 'Already fixed by an earlier ship.' });
|
|
235
|
+
assert.equal(r.ok, true);
|
|
236
|
+
assert.equal(r.value.resolved_task_id, null);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('a non-numeric task id is refused', () => {
|
|
240
|
+
const r = flags.normalizeResolution({ status: 'resolved', resolution_note: 'Fixed in the ship below.', resolved_task_id: 'abc' });
|
|
241
|
+
assert.equal(r.ok, false);
|
|
242
|
+
assert.equal(r.code, 'bad_resolved_task_id');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
246
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_no_cms.mjs — the goal's hard NON-GOAL, made
|
|
2
|
+
// testable (task 1003113 / R02 of goal 1000074).
|
|
3
|
+
//
|
|
4
|
+
// Goal 1000074, verbatim: "NO LIVE CMS. Every copy change is a claimed, graded,
|
|
5
|
+
// shipped GDS task (the methodology's hard rule). The registry is an inventory
|
|
6
|
+
// plus a work queue plus an editing affordance — never a write path that
|
|
7
|
+
// bypasses the ledger."
|
|
8
|
+
//
|
|
9
|
+
// A constraint that lives only in prose is a constraint that erodes. The way
|
|
10
|
+
// this one erodes is not a dramatic "add a CMS" commit — it is one plausible
|
|
11
|
+
// column ("just store the suggested wording so the artist doesn't lose it"),
|
|
12
|
+
// then one plausible read of that column, and the ledger rule is dead with
|
|
13
|
+
// nobody having decided to kill it. So the shape is pinned here:
|
|
14
|
+
//
|
|
15
|
+
// * the write surface is EXACTLY the two verbs R02 defines,
|
|
16
|
+
// * no column on copy_desk_flags could hold replacement text, and
|
|
17
|
+
// * nothing in this module ever hands a renderer a string as authoritative copy.
|
|
18
|
+
//
|
|
19
|
+
// R03 (task 1003114) builds the editing affordance, and its own done-when asks
|
|
20
|
+
// for the full version of this test. This is the R02-scoped seed of it: when R03
|
|
21
|
+
// adds a proposal path, the expected route list below changes DELIBERATELY, in a
|
|
22
|
+
// diff a reviewer can see, rather than drifting.
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import url from 'node:url';
|
|
28
|
+
|
|
29
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
30
|
+
const MODULE_DIR = path.join(HERE, '..');
|
|
31
|
+
|
|
32
|
+
let passed = 0;
|
|
33
|
+
let failed = 0;
|
|
34
|
+
async function test(name, fn) {
|
|
35
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
36
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const read = (rel) => fs.readFileSync(path.join(MODULE_DIR, rel), 'utf8');
|
|
40
|
+
|
|
41
|
+
// Strip comments so the prose ABOUT the rule (which necessarily names the
|
|
42
|
+
// forbidden things — the migration header says in so many words that a flag
|
|
43
|
+
// must never carry replacement text) is not mistaken for the rule being broken.
|
|
44
|
+
// Both comment dialects, because this scans JS and SQL.
|
|
45
|
+
const stripComments = (src) => src
|
|
46
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
47
|
+
.replace(/^\s*\/\/.*$/gm, ' ')
|
|
48
|
+
.replace(/--[^\n]*/g, ' ');
|
|
49
|
+
|
|
50
|
+
console.log('copy-desk: NO LIVE CMS');
|
|
51
|
+
|
|
52
|
+
await test('the write surface is exactly the two verbs R02 defines', () => {
|
|
53
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
54
|
+
const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
|
|
55
|
+
.map((m) => `${m[1].toUpperCase()} ${m[2]}`)
|
|
56
|
+
.sort();
|
|
57
|
+
assert.deepEqual(found, [
|
|
58
|
+
'POST /copy-desk/flags',
|
|
59
|
+
'POST /copy-desk/flags/:id/close',
|
|
60
|
+
], 'a new write route on this module must be a deliberate, reviewed change to this list');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
64
|
+
const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
|
|
65
|
+
// The names a "just store the suggested wording" column would plausibly take.
|
|
66
|
+
const forbidden = /\b(new_text|proposed_text|suggested_text|replacement|replacement_text|new_copy|proposed_copy|override_text|value_text)\b/i;
|
|
67
|
+
const hit = forbidden.exec(sql);
|
|
68
|
+
assert.equal(hit ? hit[0] : null, null,
|
|
69
|
+
`copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('the only text column is the historical snapshot, and it is never the live source', () => {
|
|
73
|
+
const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
|
|
74
|
+
const textish = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
|
|
75
|
+
assert.deepEqual(textish.sort(), [
|
|
76
|
+
'file_at_flag', 'kind', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
|
|
77
|
+
], 'a new text column here needs a deliberate look at whether it is copy');
|
|
78
|
+
|
|
79
|
+
// And the live read path is the registry, not the row: annotateFlag fills
|
|
80
|
+
// `current` from the registry index every time.
|
|
81
|
+
const reg = stripComments(read('registry.js'));
|
|
82
|
+
assert.ok(/current: \{ text: first\.text/.test(reg),
|
|
83
|
+
'the live text an artist sees must come from the registry entry, not from text_at_flag');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('this module never writes to the surfaces it inventories', () => {
|
|
87
|
+
// The registry is generated by R01 and committed. If this module ever gained a
|
|
88
|
+
// write to docs/copy-registry.json — or to any surface file — the inventory
|
|
89
|
+
// would stop describing the tree and start describing itself.
|
|
90
|
+
for (const rel of ['registry.js', 'flags.js', 'routes/copy-desk.js']) {
|
|
91
|
+
const src = stripComments(read(rel));
|
|
92
|
+
assert.equal(/fs\.(writeFile|writeFileSync|appendFile|appendFileSync|rm|unlink|mkdir)/.test(src), false,
|
|
93
|
+
`${rel} performs a filesystem WRITE — this module reads the registry and never writes it`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
98
|
+
const manifest = JSON.parse(read('module.json'));
|
|
99
|
+
assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
|
|
100
|
+
assert.deepEqual(manifest.consumes, []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
104
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_queue.mjs — the queue composition (task 1003113 /
|
|
2
|
+
// R02 of goal 1000074).
|
|
3
|
+
//
|
|
4
|
+
// The criterion these pin: THE QUEUE TELLS AN ARTIST THE TRUTH ABOUT ITS OWN
|
|
5
|
+
// STATE. Three ways it could lie, one test group each:
|
|
6
|
+
//
|
|
7
|
+
// 1. A flag whose string has since been edited or deleted must READ as such
|
|
8
|
+
// ('gone'), not silently vanish and not auto-close. The id is a hash over
|
|
9
|
+
// the text, so an edit produces a new id — which means "target gone" is a
|
|
10
|
+
// routine, expected state, not an error.
|
|
11
|
+
// 2. A flag whose string MOVED file or line must show where it is now, or the
|
|
12
|
+
// artist opens the wrong file.
|
|
13
|
+
// 3. The duplicate signal must subtract what has already been dealt with. A
|
|
14
|
+
// count that never moves however much work you do teaches you the queue is
|
|
15
|
+
// decorative.
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const registry = require('../registry.js');
|
|
22
|
+
|
|
23
|
+
let passed = 0;
|
|
24
|
+
let failed = 0;
|
|
25
|
+
async function test(name, fn) {
|
|
26
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
27
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const REG = {
|
|
31
|
+
schema: 1,
|
|
32
|
+
generator: 'scripts/gds/copy-inventory.js',
|
|
33
|
+
goal: 1000074,
|
|
34
|
+
counts: { strings: 3, unique_ids: 2, duplicate_ids: 1 },
|
|
35
|
+
surfaces: { 'builders-hall': { at: 'modules/hall-ui/public', files: 2, strings: 3 } },
|
|
36
|
+
blind_spots: ['server-side messages'],
|
|
37
|
+
duplicates: [
|
|
38
|
+
{ id: 'dupA', surface: 'builders-hall', text: 'Could not save.', occurrences: 2, at: ['a.js:1', 'b.js:2'] },
|
|
39
|
+
{ id: 'dupB', surface: 'builders-hall', text: 'Archons only.', occurrences: 3, at: ['c.js:1', 'd.js:2', 'e.js:3'] },
|
|
40
|
+
{ id: 'dupC', surface: 'builders-hall', text: 'Loading…', occurrences: 2, at: ['f.js:1', 'g.js:2'] },
|
|
41
|
+
],
|
|
42
|
+
entries: [
|
|
43
|
+
{ id: 'dupA', surface: 'builders-hall', text: 'Could not save.', file: 'a.js', line: 1, origin: 'js-notice' },
|
|
44
|
+
{ id: 'dupA', surface: 'builders-hall', text: 'Could not save.', file: 'b.js', line: 2, origin: 'js-notice' },
|
|
45
|
+
{ id: 'live', surface: 'builders-hall', text: 'Enter.', file: 'h.js', line: 42, origin: 'html-text' },
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
const INDEX = registry.indexRegistry(REG);
|
|
49
|
+
|
|
50
|
+
const flag = (o) => ({
|
|
51
|
+
id: 1, scope: 'string', surface: 'builders-hall', string_id: 'live',
|
|
52
|
+
reason: 'x', kind: 'other', status: 'open',
|
|
53
|
+
text_at_flag: 'Enter.', file_at_flag: 'h.js', line_at_flag: 42, ...o,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log('copy-desk: what the queue says about a flag');
|
|
57
|
+
|
|
58
|
+
await test('a flag whose string is still there reads present, with the live text', () => {
|
|
59
|
+
const a = registry.annotateFlag(INDEX, flag({}));
|
|
60
|
+
assert.equal(a.target_state, 'present');
|
|
61
|
+
assert.equal(a.current.text, 'Enter.');
|
|
62
|
+
assert.equal(a.current.occurrences, 1);
|
|
63
|
+
assert.equal(a.moved, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await test('a flag whose string was edited or deleted reads GONE, and is not auto-closed', () => {
|
|
67
|
+
const a = registry.annotateFlag(INDEX, flag({ string_id: 'was-edited-away' }));
|
|
68
|
+
assert.equal(a.target_state, 'gone');
|
|
69
|
+
assert.equal(a.current, null);
|
|
70
|
+
// The status is untouched. Closing someone's judgement because a hash moved is
|
|
71
|
+
// exactly the silent wrong this goal exists to stop — 'gone' is a HINT to a
|
|
72
|
+
// person, never a verdict.
|
|
73
|
+
assert.equal(a.status, 'open');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('a flag whose string moved file or line shows where it is NOW', () => {
|
|
77
|
+
const a = registry.annotateFlag(INDEX, flag({ file_at_flag: 'old.js', line_at_flag: 7 }));
|
|
78
|
+
assert.equal(a.target_state, 'present');
|
|
79
|
+
assert.equal(a.moved, true);
|
|
80
|
+
assert.equal(a.current.file, 'h.js');
|
|
81
|
+
assert.equal(a.current.line, 42);
|
|
82
|
+
// The snapshot is preserved beside it — the artist can see both.
|
|
83
|
+
assert.equal(a.file_at_flag, 'old.js');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a surface-scoped flag has no target state to report', () => {
|
|
87
|
+
const a = registry.annotateFlag(INDEX, flag({ scope: 'surface', string_id: null }));
|
|
88
|
+
assert.equal(a.target_state, 'n/a');
|
|
89
|
+
assert.equal(a.current, null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
console.log('copy-desk: the duplicate signal');
|
|
93
|
+
|
|
94
|
+
await test('duplicates come from the registry, not recomputed here', () => {
|
|
95
|
+
const d = registry.duplicateCandidates(REG, new Set(), new Set());
|
|
96
|
+
assert.equal(d.length, 3);
|
|
97
|
+
assert.deepEqual(d.map((x) => x.id), ['dupA', 'dupB', 'dupC']);
|
|
98
|
+
assert.equal(d[1].occurrences, 3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('an already-flagged duplicate is marked flagged', () => {
|
|
102
|
+
const d = registry.duplicateCandidates(REG, new Set(['dupA']), new Set());
|
|
103
|
+
assert.equal(d.find((x) => x.id === 'dupA').flagged, true);
|
|
104
|
+
assert.equal(d.find((x) => x.id === 'dupB').flagged, false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('a duplicate someone looked at and DISMISSED is settled, not untouched', () => {
|
|
108
|
+
// Otherwise a judgement call ("this repetition is deliberate") reappears as
|
|
109
|
+
// fresh work forever, and the queue trains people to ignore it.
|
|
110
|
+
const d = registry.duplicateCandidates(REG, new Set(), new Set(['dupB']));
|
|
111
|
+
assert.equal(d.find((x) => x.id === 'dupB').settled, true);
|
|
112
|
+
assert.equal(d.find((x) => x.id === 'dupB').flagged, false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('an open flag wins over a closed one for the same string', () => {
|
|
116
|
+
// A string flagged, resolved, then flagged again is OPEN work.
|
|
117
|
+
const d = registry.duplicateCandidates(REG, new Set(['dupC']), new Set(['dupC']));
|
|
118
|
+
const c = d.find((x) => x.id === 'dupC');
|
|
119
|
+
assert.equal(c.flagged, true);
|
|
120
|
+
assert.equal(c.settled, false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
console.log('copy-desk: the counts the surface leads with');
|
|
124
|
+
|
|
125
|
+
await test('untouched duplicates subtract both flagged and settled', () => {
|
|
126
|
+
const q = registry.composeQueue({
|
|
127
|
+
registry: REG,
|
|
128
|
+
index: INDEX,
|
|
129
|
+
flags: [
|
|
130
|
+
flag({ id: 1, string_id: 'dupA', status: 'open' }),
|
|
131
|
+
flag({ id: 2, string_id: 'dupB', status: 'dismissed' }),
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
assert.equal(q.counts.duplicates_total, 3);
|
|
135
|
+
assert.equal(q.counts.duplicates_untouched, 1, 'only dupC is untouched');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('the "target gone" pile is counted separately — it is the fastest to clear', () => {
|
|
139
|
+
const q = registry.composeQueue({
|
|
140
|
+
registry: REG,
|
|
141
|
+
index: INDEX,
|
|
142
|
+
flags: [
|
|
143
|
+
flag({ id: 1, string_id: 'live', status: 'open' }),
|
|
144
|
+
flag({ id: 2, string_id: 'vanished', status: 'open' }),
|
|
145
|
+
flag({ id: 3, string_id: 'also-vanished', status: 'open' }),
|
|
146
|
+
// A CLOSED flag on a vanished string is not outstanding work.
|
|
147
|
+
flag({ id: 4, string_id: 'long-gone', status: 'resolved' }),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
assert.equal(q.counts.flags_open, 3);
|
|
151
|
+
assert.equal(q.counts.flags_target_gone, 2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('the counts describe EVERY flag in scope, not the filtered slice', () => {
|
|
155
|
+
// The bug this pins: viewing "resolved" would report "0 open flags" and
|
|
156
|
+
// re-surface every dismissed duplicate as untouched work. A stat panel that
|
|
157
|
+
// changes meaning when you change a filter is worse than no stat panel.
|
|
158
|
+
const every = [
|
|
159
|
+
flag({ id: 1, string_id: 'dupA', status: 'open' }),
|
|
160
|
+
flag({ id: 2, string_id: 'dupB', status: 'dismissed' }),
|
|
161
|
+
flag({ id: 3, string_id: 'live', status: 'resolved' }),
|
|
162
|
+
];
|
|
163
|
+
const q = registry.composeQueue({
|
|
164
|
+
registry: REG,
|
|
165
|
+
index: INDEX,
|
|
166
|
+
flags: every.filter((f) => f.status === 'resolved'), // the filtered view
|
|
167
|
+
allFlags: every,
|
|
168
|
+
});
|
|
169
|
+
assert.equal(q.counts.flags_shown, 1, 'flags_shown is the only count about the filtered list');
|
|
170
|
+
assert.equal(q.counts.flags_open, 1, 'there IS one open flag, even though the view is "resolved"');
|
|
171
|
+
assert.equal(q.counts.duplicates_untouched, 1, 'dupB stays settled while the view is filtered');
|
|
172
|
+
assert.equal(q.duplicates.find((d) => d.id === 'dupA').flagged, true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('allFlags defaults to flags for the unfiltered call', () => {
|
|
176
|
+
const rows = [flag({ id: 1, string_id: 'dupA', status: 'open' })];
|
|
177
|
+
const q = registry.composeQueue({ registry: REG, index: INDEX, flags: rows });
|
|
178
|
+
assert.equal(q.counts.flags_open, 1);
|
|
179
|
+
assert.equal(q.duplicates.find((d) => d.id === 'dupA').flagged, true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('the payload names the artifact it is describing and how to check it is fresh', () => {
|
|
183
|
+
const q = registry.composeQueue({ registry: REG, index: INDEX, flags: [] });
|
|
184
|
+
assert.equal(q.registry.at, 'docs/copy-registry.json');
|
|
185
|
+
assert.ok(q.registry.freshness_check.includes('--check'));
|
|
186
|
+
assert.equal(q.registry.schema, 1);
|
|
187
|
+
// Deliberately NO generated_at: the generator is deterministic and carries no
|
|
188
|
+
// clock, so a timestamp here could only be the checkout's, dressed up as the
|
|
189
|
+
// generation's. See composeQueue's note.
|
|
190
|
+
assert.equal(q.registry.generated_at, undefined);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('the blind spots R01 documented travel with the queue', () => {
|
|
194
|
+
// The registry knows what it cannot see (server-side messages, skill prose,
|
|
195
|
+
// runtime-composed strings). An artist reading a queue built from it needs to
|
|
196
|
+
// know the inventory is partial, or they will read "1279 strings" as "all of them".
|
|
197
|
+
const q = registry.composeQueue({ registry: REG, index: INDEX, flags: [] });
|
|
198
|
+
assert.deepEqual(q.registry.blind_spots, ['server-side messages']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.log('copy-desk: finding a string to flag');
|
|
202
|
+
|
|
203
|
+
await test('search matches on the text', () => {
|
|
204
|
+
const r = registry.searchEntries(REG, { q: 'could not' });
|
|
205
|
+
assert.equal(r.matched, 2);
|
|
206
|
+
assert.equal(r.entries.length, 2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test('search matches on the file path too — an engineer looks by file', () => {
|
|
210
|
+
const r = registry.searchEntries(REG, { q: 'h.js' });
|
|
211
|
+
assert.equal(r.matched, 1);
|
|
212
|
+
assert.equal(r.entries[0].id, 'live');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test('search is scoped by surface when asked', () => {
|
|
216
|
+
assert.equal(registry.searchEntries(REG, { q: '', surface: 'nope' }).matched, 0);
|
|
217
|
+
assert.equal(registry.searchEntries(REG, { q: '', surface: 'builders-hall' }).matched, 3);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('already-flagged strings are marked, so nobody writes a reason to earn a 409', () => {
|
|
221
|
+
const r = registry.searchEntries(REG, { q: 'Enter' }, new Set(['live']));
|
|
222
|
+
assert.equal(r.entries[0].flagged, true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await test('a truncated result SAYS it is truncated, and how many it matched', () => {
|
|
226
|
+
// A silently capped list reads as a complete one — an artist would conclude a
|
|
227
|
+
// string simply is not in the product.
|
|
228
|
+
const r = registry.searchEntries(REG, { q: '', limit: 1 });
|
|
229
|
+
assert.equal(r.entries.length, 1);
|
|
230
|
+
assert.equal(r.matched, 3);
|
|
231
|
+
assert.equal(r.truncated, true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('the limit is clamped, not trusted', () => {
|
|
235
|
+
assert.equal(registry.searchEntries(REG, { limit: 100000 }).limit, 200);
|
|
236
|
+
assert.equal(registry.searchEntries(REG, { limit: -5 }).limit, 50);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
console.log('copy-desk: a missing registry is a named state, not a crash');
|
|
240
|
+
|
|
241
|
+
await test('loadRegistry on an absent file returns a named code and the fix', () => {
|
|
242
|
+
// Simulated by pointing the loader at a path that cannot exist is not possible
|
|
243
|
+
// without touching module internals, so this asserts the SHAPE the routes rely
|
|
244
|
+
// on: either { ok:true, registry, index } or { ok:false, code, detail }.
|
|
245
|
+
const loaded = registry.loadRegistry();
|
|
246
|
+
assert.equal(typeof loaded.ok, 'boolean');
|
|
247
|
+
if (loaded.ok) {
|
|
248
|
+
assert.ok(loaded.registry && loaded.index, 'a loaded registry carries both halves');
|
|
249
|
+
assert.ok(loaded.index.surfaces.size > 0, 'the real committed registry has surfaces');
|
|
250
|
+
} else {
|
|
251
|
+
assert.ok(['registry_missing', 'registry_unreadable'].includes(loaded.code));
|
|
252
|
+
assert.ok(loaded.detail.expected_at, 'the refusal says where the file should be');
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
257
|
+
process.exit(failed ? 1 : 0);
|