@bongos/core 1.19.492 → 1.19.493
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 +54 -34
- 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/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +106 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +26 -14
- package/docs/copy-registry.json +123 -15
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/proposals.js +278 -0
- package/modules/copy-desk/routes/copy-desk.js +173 -9
- package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
- package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/copy-desk.css +9 -1
- package/modules/hall-ui/public/copy-desk.html +33 -0
- package/modules/hall-ui/public/copy-desk.js +134 -3
- package/modules/hall-ui/public/copy-desk.states.json +18 -1
- package/modules/lifecycle/lifecycle.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +338 -0
- package/scripts/gds/copy-inventory.js +128 -30
- package/src/module-api.js +1 -1
- package/tests/hall_collab_world.mjs +24 -4
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// modules/copy-desk/proposals.js — the PROPOSAL: an artist's replacement wording
|
|
2
|
+
// for one string, carried as a patch on a GDS task (task 1003114 / R03 of goal
|
|
3
|
+
// 1000074, the artist's loop).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS. R02 gave the artist a way to say "this sentence is
|
|
6
|
+
// wrong" (the flag). It deliberately gave them no way to say what it should say
|
|
7
|
+
// instead, because the obvious place to put that — a column next to the flag —
|
|
8
|
+
// is the exact shape goal 1000074 forbids: "NO LIVE CMS ... never a write path
|
|
9
|
+
// that bypasses the ledger" (and ADR 0178 §3, which pins that copy_desk_flags
|
|
10
|
+
// has no column that could hold replacement text and must never gain one).
|
|
11
|
+
//
|
|
12
|
+
// THE SHAPE THAT SATISFIES BOTH HALVES, and it is borrowed rather than invented:
|
|
13
|
+
// ADR 0081 already solved this problem for the DESIGN half. A tool authors, an
|
|
14
|
+
// adapter EXPORTS its output into the repo as a committed diff under a claimed
|
|
15
|
+
// task, and the repo stays the source of truth (modules/ui-design/adapters/*,
|
|
16
|
+
// `adapter.export()`). This is that contract for TEXT. The artist is the tool;
|
|
17
|
+
// the copy desk is where they author; the proposal is the export. So a proposal
|
|
18
|
+
// is not a row this module keeps — it is a PATCH, rendered into the description
|
|
19
|
+
// of a task, which is the ledger the methodology already requires every change
|
|
20
|
+
// to pass through. Nothing renders from it; scripts/gds/copy-apply.js is the
|
|
21
|
+
// only reader, and it runs under a claim.
|
|
22
|
+
//
|
|
23
|
+
// WHERE THE PATCH LIVES, AND WHY IT IS A FENCED BLOCK. It has to be readable by
|
|
24
|
+
// a person reviewing the task (the whole point is that the change is arguable
|
|
25
|
+
// before it lands) and parseable by the applier. A fenced ```copy-proposal block
|
|
26
|
+
// in the task description is both, renders as itself in the hall, and survives a
|
|
27
|
+
// person editing the prose around it. It is deliberately NOT a database column
|
|
28
|
+
// anywhere: a column is one join away from being read as copy, which is the
|
|
29
|
+
// erosion ADR 0178 describes.
|
|
30
|
+
//
|
|
31
|
+
// PURE — no DB, no fs, no clock. Both callers import it: the route
|
|
32
|
+
// (routes/copy-desk.js) composes, the applier (scripts/gds/copy-apply.js)
|
|
33
|
+
// parses, and neither owns a second copy of the format.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
// The patch format's version. The applier refuses anything else BY NAME rather
|
|
38
|
+
// than doing its best with a shape it does not know — a half-understood patch
|
|
39
|
+
// edits the wrong characters of a real file.
|
|
40
|
+
const PROPOSAL_SCHEMA = 1;
|
|
41
|
+
|
|
42
|
+
// The fence tag. Chosen to be unmistakable in a task description and impossible
|
|
43
|
+
// to produce by accident.
|
|
44
|
+
const FENCE = 'copy-proposal';
|
|
45
|
+
|
|
46
|
+
// The same judgement floor R02 put on a flag's reason, for the same reason
|
|
47
|
+
// (ADR 0178 §2): a proposal with no argument hands a reviewer a diff and no way
|
|
48
|
+
// to decide it, which costs them more than not proposing. The DB CHECK that
|
|
49
|
+
// backs the flag's floor has no equivalent here because there is no row — the
|
|
50
|
+
// floor is enforced at the one write vector, which is this file's caller.
|
|
51
|
+
const MIN_WHY_CHARS = 10;
|
|
52
|
+
const MAX_WHY_CHARS = 2000;
|
|
53
|
+
|
|
54
|
+
// 400 is the registry's own upper bound on what counts as one string
|
|
55
|
+
// (`looksLikeCopy` in scripts/gds/copy-inventory.js). Proposing something longer
|
|
56
|
+
// than the inventory can hold would produce a patch that lands and then vanishes
|
|
57
|
+
// from the next registry — a change that silently un-inventories itself.
|
|
58
|
+
const MAX_TEXT_CHARS = 400;
|
|
59
|
+
|
|
60
|
+
// The interpolation hole the inventory writes for `${...}`. A proposal must keep
|
|
61
|
+
// exactly as many as the current text has, in the same order: the applier maps
|
|
62
|
+
// the k-th hole back to the k-th expression it found in the source. Reordering
|
|
63
|
+
// or dropping one is a code change, not a copy change, and is refused by name.
|
|
64
|
+
const PLACEHOLDER = '{…}';
|
|
65
|
+
|
|
66
|
+
const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
|
|
67
|
+
|
|
68
|
+
// Collapse whitespace runs and trim — the same normalization the inventory
|
|
69
|
+
// applies when it records a string, so a proposal is measured and stored in the
|
|
70
|
+
// form it will actually be compared against.
|
|
71
|
+
function tidy(raw) {
|
|
72
|
+
return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function placeholderCount(text) {
|
|
76
|
+
return String(text || '').split(PLACEHOLDER).length - 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate one POSTed proposal into the patch the task will carry.
|
|
80
|
+
//
|
|
81
|
+
// `entry` is the registry occurrence being changed, and it is REQUIRED and
|
|
82
|
+
// server-side. Every fact about the TARGET — surface, file, line, render path,
|
|
83
|
+
// and the current wording — is copied from it and never from the request body,
|
|
84
|
+
// which is R02's rule for a flag's snapshot (ADR 0178 §6) and matters more here:
|
|
85
|
+
// a caller-supplied "current text" would let a proposal describe a change to a
|
|
86
|
+
// sentence that does not exist, and the applier would then be editing a file on
|
|
87
|
+
// the strength of a claim nobody checked.
|
|
88
|
+
//
|
|
89
|
+
// PURE. The caller resolves `entry` from the loaded registry first.
|
|
90
|
+
function normalizeProposalPayload({ body = {}, entry, builderId = null } = {}) {
|
|
91
|
+
if (!entry || !entry.file || !entry.text) return fail('unknown_target');
|
|
92
|
+
|
|
93
|
+
const proposed = tidy(body.proposed_text);
|
|
94
|
+
const why = tidy(body.why);
|
|
95
|
+
const current = tidy(entry.text);
|
|
96
|
+
|
|
97
|
+
if (!proposed) {
|
|
98
|
+
return fail('proposed_text_missing', {
|
|
99
|
+
what_this_means: 'A proposal is a replacement wording. Without one there is nothing to land — file a flag instead if you only want to mark this as wrong.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (proposed.length > MAX_TEXT_CHARS) {
|
|
103
|
+
return fail('proposed_text_too_long', { max: MAX_TEXT_CHARS, was: proposed.length });
|
|
104
|
+
}
|
|
105
|
+
if (proposed === current) {
|
|
106
|
+
return fail('proposed_text_unchanged', {
|
|
107
|
+
what_this_means: 'The proposed wording is the current wording. Nothing would change.',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (why.length < MIN_WHY_CHARS) {
|
|
111
|
+
return fail('why_too_thin', {
|
|
112
|
+
min: MIN_WHY_CHARS,
|
|
113
|
+
what_this_means: 'A reviewer has to be able to decide this without asking you. Say what is wrong with the current wording and what the new one does better — the same floor a flag’s reason carries.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (why.length > MAX_WHY_CHARS) {
|
|
117
|
+
return fail('why_too_long', { max: MAX_WHY_CHARS, was: why.length });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The placeholder arithmetic. This is the one rule that makes the patch
|
|
121
|
+
// MECHANICALLY applicable rather than a note for an engineer: the applier
|
|
122
|
+
// re-injects the source's own interpolations into the holes, so the count has
|
|
123
|
+
// to match and the order has to be the author's intent.
|
|
124
|
+
const want = placeholderCount(current);
|
|
125
|
+
const got = placeholderCount(proposed);
|
|
126
|
+
if (want !== got) {
|
|
127
|
+
return fail('placeholder_mismatch', {
|
|
128
|
+
expected: want,
|
|
129
|
+
got,
|
|
130
|
+
placeholder: PLACEHOLDER,
|
|
131
|
+
what_this_means: want === 0
|
|
132
|
+
? `This string has no ${PLACEHOLDER} holes, so the new wording must not introduce any.`
|
|
133
|
+
: `This string is assembled at render time and has ${want} ${PLACEHOLDER} hole(s) where a value is dropped in. Keep all ${want}, in the order the sentence needs them — moving or removing one is a code change, not a copy change.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
patch: {
|
|
140
|
+
schema: PROPOSAL_SCHEMA,
|
|
141
|
+
string_id: entry.id,
|
|
142
|
+
surface: entry.surface,
|
|
143
|
+
file: entry.file,
|
|
144
|
+
line: entry.line,
|
|
145
|
+
origin: entry.origin,
|
|
146
|
+
current,
|
|
147
|
+
proposed,
|
|
148
|
+
why,
|
|
149
|
+
proposed_by: builderId ?? null,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Render the patch as the fenced block that goes in the task's description.
|
|
155
|
+
// JSON with a stable key order and two-space indent: a reviewer reads it, and a
|
|
156
|
+
// diff of two proposals against the same string lines up.
|
|
157
|
+
function composeProposalBlock(patch) {
|
|
158
|
+
const ordered = {
|
|
159
|
+
schema: patch.schema,
|
|
160
|
+
string_id: patch.string_id,
|
|
161
|
+
surface: patch.surface,
|
|
162
|
+
file: patch.file,
|
|
163
|
+
line: patch.line,
|
|
164
|
+
origin: patch.origin,
|
|
165
|
+
current: patch.current,
|
|
166
|
+
proposed: patch.proposed,
|
|
167
|
+
};
|
|
168
|
+
return ['```' + FENCE, JSON.stringify(ordered, null, 2), '```'].join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The reverse. Returns the FIRST block in the text: a task description carries
|
|
172
|
+
// exactly one proposal, and if someone has pasted a second the applier must not
|
|
173
|
+
// silently pick one — so a second block is a named refusal, not a coin flip.
|
|
174
|
+
function parseProposalBlock(markdown) {
|
|
175
|
+
const src = String(markdown || '');
|
|
176
|
+
const re = new RegExp('```' + FENCE + '\\s*\\n([\\s\\S]*?)\\n```', 'g');
|
|
177
|
+
const found = [...src.matchAll(re)];
|
|
178
|
+
if (!found.length) return fail('no_proposal_block', { fence: FENCE });
|
|
179
|
+
if (found.length > 1) {
|
|
180
|
+
return fail('multiple_proposal_blocks', {
|
|
181
|
+
count: found.length,
|
|
182
|
+
what_this_means: 'This task carries more than one proposal. Split them: one task changes one string, so the grade and the diff are about one decision.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
let patch;
|
|
186
|
+
try {
|
|
187
|
+
patch = JSON.parse(found[0][1]);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return fail('proposal_block_unreadable', { reason: err.message });
|
|
190
|
+
}
|
|
191
|
+
if (Number(patch && patch.schema) !== PROPOSAL_SCHEMA) {
|
|
192
|
+
return fail('proposal_schema_unknown', { expected: PROPOSAL_SCHEMA, got: patch && patch.schema });
|
|
193
|
+
}
|
|
194
|
+
for (const field of ['string_id', 'surface', 'file', 'origin', 'current', 'proposed']) {
|
|
195
|
+
if (typeof patch[field] !== 'string' || !patch[field]) {
|
|
196
|
+
return fail('proposal_field_missing', { field });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!Number.isInteger(patch.line) || patch.line < 1) {
|
|
200
|
+
return fail('proposal_field_missing', { field: 'line' });
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, patch };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The task a proposal becomes. Title names the surface and quotes enough of the
|
|
206
|
+
// current wording to be recognizable in a board list; the body carries the
|
|
207
|
+
// artist's argument, the patch, and the one command that lands it.
|
|
208
|
+
function proposalTaskTitle(patch) {
|
|
209
|
+
const snippet = patch.current.length > 60 ? `${patch.current.slice(0, 57)}…` : patch.current;
|
|
210
|
+
return `Copy: reword “${snippet}” on ${patch.surface}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The task's own instructions. They must be RUNNABLE AS WRITTEN — a placeholder
|
|
214
|
+
// a claimer has to substitute is a step to get wrong, and this is the one
|
|
215
|
+
// handoff between the person who wrote the words and the person who lands them.
|
|
216
|
+
//
|
|
217
|
+
// The task cannot know its own id at the moment its description is composed, so
|
|
218
|
+
// the command takes none: `copy-apply.js` with no argument acts on the task you
|
|
219
|
+
// are HOLDING. That is not a convenience, it is the rule — a copy change lands
|
|
220
|
+
// under a claim, and a claim already names a task.
|
|
221
|
+
function proposalTaskBody(patch) {
|
|
222
|
+
return [
|
|
223
|
+
`An artist proposed new wording for one string on the \`${patch.surface}\` surface.`,
|
|
224
|
+
'',
|
|
225
|
+
'**Why**',
|
|
226
|
+
'',
|
|
227
|
+
patch.why,
|
|
228
|
+
'',
|
|
229
|
+
'**The change**',
|
|
230
|
+
'',
|
|
231
|
+
`- current: ${JSON.stringify(patch.current)}`,
|
|
232
|
+
`- proposed: ${JSON.stringify(patch.proposed)}`,
|
|
233
|
+
`- where: \`${patch.file}:${patch.line}\` (${patch.origin})`,
|
|
234
|
+
'',
|
|
235
|
+
'**How it lands**',
|
|
236
|
+
'',
|
|
237
|
+
'This is a proposal, not a change — nothing has been written. Claim this task and run:',
|
|
238
|
+
'',
|
|
239
|
+
'```bash',
|
|
240
|
+
'node scripts/gds/copy-apply.js',
|
|
241
|
+
'```',
|
|
242
|
+
'',
|
|
243
|
+
'That re-derives the string’s position in the working tree, replaces exactly those characters, regenerates the copy registry and prints the diff for review. Then ship it like any other change — the grader sees the real diff.',
|
|
244
|
+
'',
|
|
245
|
+
'If the applier refuses, it says why in one line; a refusal means the file moved on under the proposal and a person should look, not that the wording was wrong.',
|
|
246
|
+
'',
|
|
247
|
+
composeProposalBlock(patch),
|
|
248
|
+
].join('\n');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The (source, source_ref) pair the create-vector idempotency read keys on
|
|
252
|
+
// (db.findTaskBySourceRef). Keyed on the string AND the proposed wording, so a
|
|
253
|
+
// double-submitted proposal resolves to the task it already made, while a
|
|
254
|
+
// genuinely different second attempt at the same string is a new task — which is
|
|
255
|
+
// right: two people can disagree about the fix, and the board should hold both.
|
|
256
|
+
function proposalSourceRef(patch) {
|
|
257
|
+
let hash = 5381;
|
|
258
|
+
const material = `${patch.string_id}${patch.file}${patch.line}${patch.proposed}`;
|
|
259
|
+
for (let i = 0; i < material.length; i += 1) hash = (((hash << 5) + hash) ^ material.charCodeAt(i)) >>> 0;
|
|
260
|
+
return `copy-proposal-${patch.string_id}-${hash.toString(36)}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
PROPOSAL_SCHEMA,
|
|
265
|
+
FENCE,
|
|
266
|
+
PLACEHOLDER,
|
|
267
|
+
MIN_WHY_CHARS,
|
|
268
|
+
MAX_WHY_CHARS,
|
|
269
|
+
MAX_TEXT_CHARS,
|
|
270
|
+
tidy,
|
|
271
|
+
placeholderCount,
|
|
272
|
+
normalizeProposalPayload,
|
|
273
|
+
composeProposalBlock,
|
|
274
|
+
parseProposalBlock,
|
|
275
|
+
proposalTaskTitle,
|
|
276
|
+
proposalTaskBody,
|
|
277
|
+
proposalSourceRef,
|
|
278
|
+
};
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// modules/copy-desk/routes/copy.js — the copy-flag surface (task 1003113 / R02 of
|
|
2
2
|
// goal 1000074, the artist's loop).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Four jobs (the fourth added by task 1003114 / R03 — see below):
|
|
5
5
|
// GET /copy-desk/queue — the artist's working list: open flags with
|
|
6
6
|
// live registry state attached, plus the
|
|
7
7
|
// deterministic duplicate candidates.
|
|
8
8
|
// POST /copy-desk/flags — file a flag. ANY signed-in builder, any role.
|
|
9
9
|
// POST /copy-desk/flags/:id/close — resolve or dismiss one, with a note.
|
|
10
|
+
// POST /copy-desk/proposals — propose new wording. Creates a TASK carrying
|
|
11
|
+
// the patch; writes no file and stores no copy.
|
|
10
12
|
//
|
|
11
13
|
// THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
|
|
12
14
|
//
|
|
@@ -24,10 +26,14 @@
|
|
|
24
26
|
// The refusal names both paths rather than dead-ending on a bare 403.
|
|
25
27
|
//
|
|
26
28
|
// WHAT DOES NOT EXIST HERE, AND MUST NOT: any route that writes a user-facing
|
|
27
|
-
// string. NO LIVE CMS is the goal's hard non-goal
|
|
28
|
-
// editing affordance
|
|
29
|
-
//
|
|
30
|
-
//
|
|
29
|
+
// string. NO LIVE CMS is the goal's hard non-goal, and R03 kept it while adding
|
|
30
|
+
// the editing affordance the goal also asked for — by making the affordance
|
|
31
|
+
// produce a TASK carrying a patch, never a write. A flag is a work request; a
|
|
32
|
+
// proposal is a work request with a suggested fix attached. Neither reaches a
|
|
33
|
+
// reader. modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this
|
|
34
|
+
// router's write surface stays exactly these three verbs, that none of them
|
|
35
|
+
// touches the filesystem, and that the one place a proposed wording is allowed
|
|
36
|
+
// to rest is the GDS ledger.
|
|
31
37
|
//
|
|
32
38
|
// Same idioms as modules/security/routes/*.js: doorway-only requires, res.fail
|
|
33
39
|
// codes, factory export mounted from module.json contributes.
|
|
@@ -38,9 +44,16 @@ const express = require('express');
|
|
|
38
44
|
const api = require('../../../src/module-api');
|
|
39
45
|
const flags = require('../flags');
|
|
40
46
|
const registry = require('../registry');
|
|
47
|
+
const proposals = require('../proposals');
|
|
41
48
|
|
|
42
49
|
const { validateOrRespond } = api;
|
|
43
50
|
|
|
51
|
+
// The doorway's namespaced logger, not console.error (task 1003114). Same seam
|
|
52
|
+
// builder-settings / dev-box / ideas / government already use: a module's log
|
|
53
|
+
// lines come out structured and attributable, and an error object logged as
|
|
54
|
+
// `{ err }` keeps its stack instead of being stringified into one line.
|
|
55
|
+
const log = api.logger('copy-desk');
|
|
56
|
+
|
|
44
57
|
// The write schemas (ADR 0118): strict by default, so an unknown or typo'd field
|
|
45
58
|
// is REJECTED rather than silently dropped. Built once at module scope — the
|
|
46
59
|
// [...flags.KINDS] spreads mean a renamed export crashes at boot with a named
|
|
@@ -61,6 +74,19 @@ const CLOSE_BODY_SCHEMA = Object.freeze({
|
|
|
61
74
|
resolution_note: { required: true, type: 'string', maxLength: flags.MAX_NOTE_CHARS, minLength: 1 },
|
|
62
75
|
resolved_task_id: { type: 'integer', min: 1 },
|
|
63
76
|
});
|
|
77
|
+
// The proposal body (task 1003114 / R03). `file` and `line` are OPTIONAL and only
|
|
78
|
+
// disambiguate a string that appears at several sites — see the route. Notice
|
|
79
|
+
// what is absent and must stay absent: nothing here names a destination. The
|
|
80
|
+
// target is resolved from the registry by `string_id`, so a caller can propose a
|
|
81
|
+
// change to a string the product actually shows and to nothing else.
|
|
82
|
+
const PROPOSAL_BODY_SCHEMA = Object.freeze({
|
|
83
|
+
string_id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
|
|
84
|
+
proposed_text: { required: true, type: 'string', maxLength: proposals.MAX_TEXT_CHARS, minLength: 1 },
|
|
85
|
+
why: { required: true, type: 'string', maxLength: proposals.MAX_WHY_CHARS, minLength: 1 },
|
|
86
|
+
file: { type: 'string', maxLength: 400 },
|
|
87
|
+
line: { type: 'integer', min: 1 },
|
|
88
|
+
flag_id: { type: 'integer', min: 1 },
|
|
89
|
+
});
|
|
64
90
|
|
|
65
91
|
// Load the registry or answer the named "no registry" state. Returns null when
|
|
66
92
|
// it has already responded, so every handler is `if (!reg) return;`.
|
|
@@ -127,7 +153,7 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
127
153
|
res.set('Cache-Control', 'no-store');
|
|
128
154
|
res.json(queue);
|
|
129
155
|
} catch (err) {
|
|
130
|
-
|
|
156
|
+
log.error({ err }, 'GET /copy-desk/queue failed');
|
|
131
157
|
if (!res.headersSent) res.fail('copy_queue_failed', 500);
|
|
132
158
|
}
|
|
133
159
|
});
|
|
@@ -163,7 +189,7 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
163
189
|
res.set('Cache-Control', 'no-store');
|
|
164
190
|
res.json(result);
|
|
165
191
|
} catch (err) {
|
|
166
|
-
|
|
192
|
+
log.error({ err }, 'GET /copy-desk/strings failed');
|
|
167
193
|
if (!res.headersSent) res.fail('copy_search_failed', 500);
|
|
168
194
|
}
|
|
169
195
|
});
|
|
@@ -200,7 +226,7 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
200
226
|
}
|
|
201
227
|
res.status(201).json({ ok: true, flag });
|
|
202
228
|
} catch (err) {
|
|
203
|
-
|
|
229
|
+
log.error({ err }, 'POST /copy-desk/flags failed');
|
|
204
230
|
if (!res.headersSent) res.fail('copy_flag_failed', 500);
|
|
205
231
|
}
|
|
206
232
|
});
|
|
@@ -263,10 +289,148 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
263
289
|
|
|
264
290
|
res.json({ ok: true, flag });
|
|
265
291
|
} catch (err) {
|
|
266
|
-
|
|
292
|
+
log.error({ err }, 'POST /copy-desk/flags/:id/close failed');
|
|
267
293
|
if (!res.headersSent) res.fail('copy_flag_close_failed', 500);
|
|
268
294
|
}
|
|
269
295
|
});
|
|
270
296
|
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
// POST /copy-desk/proposals — an artist proposes new wording, and it becomes a
|
|
299
|
+
// TASK (task 1003114 / R03 of goal 1000074).
|
|
300
|
+
//
|
|
301
|
+
// Body: { string_id, proposed_text, why, file?, line?, flag_id? }
|
|
302
|
+
// rank: any signed-in builder — the same floor as filing, for the same reason
|
|
303
|
+
// (an artist is a craft, not a rank; see the header).
|
|
304
|
+
//
|
|
305
|
+
// THIS IS THE ROUTE THE NON-GOAL IS ABOUT, so read what it does NOT do. It
|
|
306
|
+
// writes no file. It stores no wording in any table of this module — ADR 0178
|
|
307
|
+
// §3 pins that copy_desk_flags can never hold replacement text, and this
|
|
308
|
+
// route's whole design is what makes that pin survivable rather than merely
|
|
309
|
+
// strict. What it produces is a TASK carrying a patch, in the ledger every
|
|
310
|
+
// change already has to pass through, which nothing renders from. The wording
|
|
311
|
+
// reaches a person only after somebody claims that task, runs
|
|
312
|
+
// `node scripts/gds/copy-apply.js --task <id>`, and ships the resulting diff
|
|
313
|
+
// through the normal pipeline with the grader reading it.
|
|
314
|
+
//
|
|
315
|
+
// The shape is borrowed, not invented: it is `adapter.export()` from the
|
|
316
|
+
// design half (ADR 0081) applied to text. See modules/copy-desk/proposals.js.
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
router.post('/copy-desk/proposals', api.requireBuilder, async (req, res) => {
|
|
319
|
+
try {
|
|
320
|
+
if (validateOrRespond(req, res, PROPOSAL_BODY_SCHEMA)) return;
|
|
321
|
+
|
|
322
|
+
const loaded = registryOrFail(res);
|
|
323
|
+
if (!loaded) return;
|
|
324
|
+
|
|
325
|
+
// Resolve the TARGET from the registry. Everything the patch says about
|
|
326
|
+
// what is being changed comes from here and never from the body — R02's
|
|
327
|
+
// snapshot rule (ADR 0178 §6), which matters more for a proposal than for
|
|
328
|
+
// a flag: a caller-supplied "current text" would let someone describe an
|
|
329
|
+
// edit to a sentence that does not exist, and the applier would then be
|
|
330
|
+
// editing a real file on the strength of a claim nobody checked.
|
|
331
|
+
const occurrences = registry.occurrencesOf(loaded.index, req.body.string_id);
|
|
332
|
+
if (!occurrences.length) {
|
|
333
|
+
return res.fail('unknown_string', 400, {
|
|
334
|
+
string_id: req.body.string_id,
|
|
335
|
+
what_this_means: 'The registry does not contain that string. It may have been reworded since this page loaded — reload the copy desk and look again.',
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// One proposal changes ONE occurrence. When a string is shown in several
|
|
340
|
+
// places the caller must say which, because picking for them is exactly
|
|
341
|
+
// the silent wrong this goal exists to stop — and "the same sentence in
|
|
342
|
+
// seven places" is already its own signal on this page, with its own
|
|
343
|
+
// answer (change them all, deliberately, under a claim).
|
|
344
|
+
let entry = occurrences[0];
|
|
345
|
+
if (occurrences.length > 1) {
|
|
346
|
+
const file = req.body.file ? String(req.body.file) : null;
|
|
347
|
+
const line = Number.isInteger(req.body.line) ? req.body.line : null;
|
|
348
|
+
const picked = occurrences.filter((e) => e.file === file && e.line === line);
|
|
349
|
+
if (picked.length !== 1) {
|
|
350
|
+
return res.fail('ambiguous_string', 400, {
|
|
351
|
+
string_id: req.body.string_id,
|
|
352
|
+
occurrences: occurrences.map((e) => ({ file: e.file, line: e.line })),
|
|
353
|
+
what_this_means: 'This exact sentence is shown in more than one place, so a proposal has to name which one it means. That duplication is itself worth a look — the copy desk lists it under “Said more than once”.',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
[entry] = picked;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const norm = proposals.normalizeProposalPayload({
|
|
360
|
+
body: req.body,
|
|
361
|
+
entry,
|
|
362
|
+
builderId: req.builder.id,
|
|
363
|
+
});
|
|
364
|
+
if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
|
|
365
|
+
const { patch } = norm;
|
|
366
|
+
|
|
367
|
+
// An optional link back to the flag that prompted this. Validated so a
|
|
368
|
+
// typo'd id is a named 400 rather than a task quietly claiming a lineage
|
|
369
|
+
// it does not have. The flag is NOT auto-closed: closing is a person's
|
|
370
|
+
// verdict on someone else's judgement (ADR 0178 §4/§6), and a proposal is
|
|
371
|
+
// an argument for a change, not proof the change happened.
|
|
372
|
+
let flagRef = null;
|
|
373
|
+
if (Number.isInteger(req.body.flag_id)) {
|
|
374
|
+
const flag = await flags.getFlag(req.body.flag_id);
|
|
375
|
+
if (!flag) return res.fail('flag_not_found', 404, { flag_id: req.body.flag_id });
|
|
376
|
+
flagRef = flag.id;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
380
|
+
if (!lifecycle || !lifecycle.createTask) {
|
|
381
|
+
// An instance without the lifecycle module has no tasks to land into, so
|
|
382
|
+
// there is no honest way to accept a proposal. Refuse loudly rather than
|
|
383
|
+
// take the wording and drop it — a proposal that vanishes is worse than
|
|
384
|
+
// one that was never accepted.
|
|
385
|
+
return res.fail('lifecycle_unavailable', 503, {
|
|
386
|
+
what_this_means: 'This instance cannot hold tasks, and a copy proposal is a task. Nothing was recorded.',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The version the work lands in: the one currently being built, matching
|
|
391
|
+
// how every other unattributed create-vector picks. `createTask` then
|
|
392
|
+
// defaults the goal to that version's catch-all, so a copy change is
|
|
393
|
+
// ordinary work on the board rather than a special case.
|
|
394
|
+
const versions = await lifecycle.listVersions();
|
|
395
|
+
const building = (versions || []).find((v) => v.status === 'building') || (versions || [])[0];
|
|
396
|
+
if (!building) return res.fail('no_open_version', 503);
|
|
397
|
+
|
|
398
|
+
const sourceRef = proposals.proposalSourceRef(patch);
|
|
399
|
+
const existing = lifecycle.findTaskBySourceRef
|
|
400
|
+
? await lifecycle.findTaskBySourceRef({ source: 'copy-proposal', sourceRef })
|
|
401
|
+
: null;
|
|
402
|
+
if (existing) {
|
|
403
|
+
// A double-submit resolves to the task it already made. 200, not 201:
|
|
404
|
+
// nothing was created this time, and the caller gets the same task back.
|
|
405
|
+
return res.json({ ok: true, task: existing, created: false });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const body = proposals.proposalTaskBody(patch);
|
|
409
|
+
const task = await lifecycle.createTask({
|
|
410
|
+
versionId: building.id,
|
|
411
|
+
title: proposals.proposalTaskTitle(patch),
|
|
412
|
+
description: flagRef
|
|
413
|
+
? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
|
|
414
|
+
: body,
|
|
415
|
+
// BACKLOG, deliberately. A proposal is a request, and the board's triage
|
|
416
|
+
// step is where a person decides it is worth doing — the same gate a
|
|
417
|
+
// captured idea passes. Landing it `ready` would let anyone with an
|
|
418
|
+
// opinion mint claimable work.
|
|
419
|
+
status: 'backlog',
|
|
420
|
+
kind: 'feature',
|
|
421
|
+
priority: 4,
|
|
422
|
+
touches: [patch.file],
|
|
423
|
+
source: 'copy-proposal',
|
|
424
|
+
sourceRef,
|
|
425
|
+
createdBy: req.builder.id,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
res.status(201).json({ ok: true, task, created: true, patch });
|
|
429
|
+
} catch (err) {
|
|
430
|
+
log.error({ err }, 'POST /copy-desk/proposals failed');
|
|
431
|
+
if (!res.headersSent) res.fail('copy_proposal_failed', 500);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
271
435
|
return router;
|
|
272
436
|
};
|