@bongos/core 1.19.491 → 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.
Files changed (41) hide show
  1. package/.bongos-core.json +68 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
  8. package/docs/adr/README.md +2 -0
  9. package/docs/api/openapi.json +106 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +56 -34
  12. package/docs/copy-registry.json +237 -39
  13. package/docs/design/mocks/hall/task/v1.html +7 -11
  14. package/docs/design/mocks/hall/task/v3.html +7 -11
  15. package/docs/file-map.md +2 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/copy-desk/module.json +2 -2
  18. package/modules/copy-desk/proposals.js +278 -0
  19. package/modules/copy-desk/routes/copy-desk.js +173 -9
  20. package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
  21. package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
  22. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  23. package/modules/hall-ui/public/copy-desk.css +9 -1
  24. package/modules/hall-ui/public/copy-desk.html +33 -0
  25. package/modules/hall-ui/public/copy-desk.js +134 -3
  26. package/modules/hall-ui/public/copy-desk.states.json +18 -1
  27. package/modules/hall-ui/public/task.css +71 -1
  28. package/modules/hall-ui/public/task.js +132 -13
  29. package/modules/hall-ui/public/task.states.json +1 -1
  30. package/modules/lifecycle/db-claim-reads.js +40 -0
  31. package/modules/lifecycle/db.js +4 -0
  32. package/modules/lifecycle/lifecycle.js +6 -0
  33. package/modules/lifecycle/routes/tasks.js +12 -1
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/scripts/gds/copy-apply.js +338 -0
  37. package/scripts/gds/copy-inventory.js +128 -30
  38. package/src/module-api.js +1 -1
  39. package/tests/hall_collab_world.mjs +24 -4
  40. package/tests/hall_record_world.mjs +183 -2
  41. package/tests/task_detail_includes.mjs +108 -0
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/copy-apply.js — land a copy proposal as a real diff (R03 of goal
5
+ // 1000074, the artist's loop; task 1003114).
6
+ //
7
+ // WHAT IT IS FOR. R03's done-when is that "an artist with no terminal can propose
8
+ // a copy change that reaches main through a claim". The copy desk gives them the
9
+ // first half: a proposal becomes a GDS task carrying a patch
10
+ // (modules/copy-desk/proposals.js). This command is the second half — the thing
11
+ // whoever claims that task runs. It is the direct analogue of `adapter.export()`
12
+ // in the design half (ADR 0081, modules/ui-design/adapters/*): tool output lands
13
+ // in the repo as a committed diff under a claim, with a path guard, and the repo
14
+ // stays the source of truth. Nothing about the copy path bypasses the ledger,
15
+ // because this command IS the ledger's hands.
16
+ //
17
+ // WHY IT RE-DERIVES INSTEAD OF TRUSTING THE PATCH. The patch names a file and a
18
+ // line, but a line number is stale the moment anything above it changes, and the
19
+ // registry's committed rows deliberately carry no character offsets (see the
20
+ // note in copy-inventory.js's buildRegistry). So this re-runs R01's OWN
21
+ // extractor over the working tree and finds the occurrence there. One set of
22
+ // matching rules, in one file, used by both the inventory and the applier —
23
+ // which is what stops this command from editing a byte the inventory would never
24
+ // have called copy.
25
+ //
26
+ // THE REFUSALS ARE THE FEATURE. A copy change that lands on the wrong characters
27
+ // is worse than one that does not land, so every ambiguity is a named refusal
28
+ // with the reason on one line: the string is gone, it moved and is no longer
29
+ // unique, its literal is escaped so no exact span exists, the placeholder
30
+ // arithmetic does not hold, or the file is outside a registry surface. None of
31
+ // them means the wording was wrong — they mean a person should look.
32
+ //
33
+ // Run: node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
34
+ // node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
35
+ // node scripts/gds/copy-apply.js --task 1234 (a named task, when you hold more than one)
36
+ // node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
37
+ //
38
+ // WHY THE BARE FORM DEFAULTS TO YOUR CLAIM rather than taking an id. The task a
39
+ // proposal creates has to print the exact line that lands it, and it cannot know
40
+ // its own id at the moment its description is written. Every workaround for that
41
+ // is worse than the rule it would paper over: this command may only be run under
42
+ // a claim (that is the whole point of the feature), and a claim already names a
43
+ // task. So the bare call reads the claim, and holding no claim is a refusal that
44
+ // says to go and claim the task — the methodology's own rule, enforced by the
45
+ // command instead of restated in its output.
46
+
47
+ const fs = require('node:fs');
48
+ const path = require('node:path');
49
+
50
+ const ci = require('./copy-inventory.js');
51
+ const proposals = require('../../modules/copy-desk/proposals.js');
52
+ const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
53
+
54
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
55
+ const PLACEHOLDER = proposals.PLACEHOLDER;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // PURE below this line, down to `main`. The tests drive these directly with
59
+ // string sources — no fs, no network, no task.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ // THE PATH GUARD, and it is the same rule the design adapter enforces: an export
63
+ // may only write inside the surfaces the contract knows about. Here the surface
64
+ // roots come from R01's own SURFACES table, so a patch can only ever touch a
65
+ // directory the inventory actually walks. A patch naming anything else — a
66
+ // traversal, a config file, a migration — is refused before a single byte is
67
+ // read, not after.
68
+ function surfaceRootFor(surfaceId) {
69
+ const surface = ci.SURFACES.find((s) => s.id === surfaceId);
70
+ return surface ? surface.at : null;
71
+ }
72
+
73
+ function checkPatchPath(patch) {
74
+ const root = surfaceRootFor(patch.surface);
75
+ if (!root) return { ok: false, code: 'unknown_surface', detail: { surface: patch.surface, known: ci.SURFACES.map((s) => s.id) } };
76
+ // Compare normalized, repo-relative, forward-slash paths — the same form the
77
+ // registry stores — and require the file to sit under its surface's root.
78
+ const rel = String(patch.file).replace(/\\/g, '/');
79
+ const abs = path.resolve(REPO_ROOT, rel);
80
+ const rootAbs = path.resolve(REPO_ROOT, root);
81
+ if (!abs.startsWith(rootAbs + path.sep)) {
82
+ return {
83
+ ok: false,
84
+ code: 'path_outside_surface',
85
+ detail: { file: rel, surface: patch.surface, must_be_under: root },
86
+ };
87
+ }
88
+ return { ok: true, rel, abs };
89
+ }
90
+
91
+ // Find the one occurrence this patch is about, in the tree as it stands now.
92
+ //
93
+ // Matching is on (origin, current text) within the file, NOT on the line: a line
94
+ // number is the first thing to go stale, and the registry id is explicitly
95
+ // designed to survive a string moving. The line is used only to disambiguate
96
+ // when the same sentence appears at several sites in one file — which is the
97
+ // duplicate case R01 already reports, and where guessing would be indefensible.
98
+ function locateOccurrence(source, rel, patch) {
99
+ const rows = (path.extname(rel) === '.html'
100
+ ? ci.fromHtml({ source, rel })
101
+ : ci.fromJs({ source, rel }))
102
+ .filter((r) => r.origin === patch.origin && r.text === patch.current);
103
+
104
+ if (!rows.length) {
105
+ return {
106
+ ok: false,
107
+ code: 'target_gone',
108
+ detail: {
109
+ file: rel,
110
+ looked_for: patch.current,
111
+ what_this_means: 'That exact wording is no longer in this file — it has already been edited, or the proposal was written against an older tree. Nothing was changed. Re-read the string on the copy desk and propose again if it still needs work.',
112
+ },
113
+ };
114
+ }
115
+ let row = rows[0];
116
+ if (rows.length > 1) {
117
+ const onLine = rows.filter((r) => r.line === patch.line);
118
+ if (onLine.length !== 1) {
119
+ return {
120
+ ok: false,
121
+ code: 'ambiguous_target',
122
+ detail: {
123
+ file: rel,
124
+ occurrences: rows.length,
125
+ lines: rows.map((r) => r.line),
126
+ what_this_means: 'This sentence appears more than once in this file and the proposal cannot say which one it meant. That is the duplicate case the copy desk reports — decide whether they should all change, and land it as an ordinary edit under this claim.',
127
+ },
128
+ };
129
+ }
130
+ row = onLine[0];
131
+ }
132
+ if (row.start === undefined) {
133
+ return {
134
+ ok: false,
135
+ code: 'no_exact_span',
136
+ detail: {
137
+ file: rel,
138
+ line: row.line,
139
+ what_this_means: 'This string lives inside an escaped quoted literal, so the inventory cannot point at its exact characters and this command will not guess. Edit it by hand under this claim.',
140
+ },
141
+ };
142
+ }
143
+ return { ok: true, row };
144
+ }
145
+
146
+ // Rebuild the replacement text, re-injecting the source's own interpolations.
147
+ //
148
+ // The registry writes `{…}` where the source has `${expr}`, so the artist edits a
149
+ // readable sentence with holes in it. Landing it means putting the ORIGINAL
150
+ // expressions back, in order — the k-th hole takes the k-th expression. The
151
+ // count was already checked when the proposal was made; it is checked again here
152
+ // against the real source, because the source is what is about to be written.
153
+ function reinjectInterpolations(rawSpan, proposedText) {
154
+ const expressions = String(rawSpan).match(/\$\{[^{}]*\}/g) || [];
155
+ const holes = proposals.placeholderCount(proposedText);
156
+ if (holes !== expressions.length) {
157
+ return {
158
+ ok: false,
159
+ code: 'placeholder_mismatch',
160
+ detail: {
161
+ expected: expressions.length,
162
+ got: holes,
163
+ placeholder: PLACEHOLDER,
164
+ what_this_means: 'The values this sentence drops in at render time do not line up with the proposed wording. Nothing was changed.',
165
+ },
166
+ };
167
+ }
168
+ let out = '';
169
+ let rest = String(proposedText);
170
+ for (const expr of expressions) {
171
+ const at = rest.indexOf(PLACEHOLDER);
172
+ out += rest.slice(0, at) + expr;
173
+ rest = rest.slice(at + PLACEHOLDER.length);
174
+ }
175
+ return { ok: true, text: out + rest };
176
+ }
177
+
178
+ // The whole edit, as a pure function of the file's current contents and a patch.
179
+ // Returns the new source — or a named refusal. Writes nothing.
180
+ function applyPatchToSource(source, rel, patch) {
181
+ const located = locateOccurrence(source, rel, patch);
182
+ if (!located.ok) return located;
183
+ const { row } = located;
184
+ const rawSpan = source.slice(row.start, row.end);
185
+ const rebuilt = reinjectInterpolations(rawSpan, patch.proposed);
186
+ if (!rebuilt.ok) return rebuilt;
187
+ return {
188
+ ok: true,
189
+ line: row.line,
190
+ before: rawSpan,
191
+ after: rebuilt.text,
192
+ source: source.slice(0, row.start) + rebuilt.text + source.slice(row.end),
193
+ };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // The command.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function refuse(result) {
201
+ const detail = result.detail || {};
202
+ console.error(`copy-apply: REFUSED (${result.code})`);
203
+ if (detail.what_this_means) console.error(` ${detail.what_this_means}`);
204
+ for (const [k, v] of Object.entries(detail)) {
205
+ if (k === 'what_this_means') continue;
206
+ console.error(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
207
+ }
208
+ return 2;
209
+ }
210
+
211
+ // The task this session is holding. Returns a named refusal rather than an id
212
+ // when there is nothing to act under — "claim it first" is the correct answer,
213
+ // not an inconvenience to route around.
214
+ async function claimedTaskId() {
215
+ const me = await apiCall('GET', '/api/gds/me');
216
+ if (!me.ok) return { ok: false, code: 'no_session', detail: { status: me.status, run: 'node scripts/gds/setup.js' } };
217
+ const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
218
+ if (!claims.length) {
219
+ return {
220
+ ok: false,
221
+ code: 'no_active_claim',
222
+ detail: {
223
+ what_this_means: 'A copy change lands under a claim like every other change. Claim the proposal task first (node scripts/gds/claim.js <id>), then run this again — or name one with --task <id>.',
224
+ },
225
+ };
226
+ }
227
+ if (claims.length > 1) {
228
+ return {
229
+ ok: false,
230
+ code: 'several_active_claims',
231
+ detail: {
232
+ held: claims.map((c) => c.task_id),
233
+ what_this_means: 'You hold more than one claim, so this command will not guess which proposal you meant. Name it with --task <id>.',
234
+ },
235
+ };
236
+ }
237
+ return { ok: true, taskId: claims[0].task_id };
238
+ }
239
+
240
+ async function loadPatch({ taskId, patchFile }) {
241
+ if (patchFile) {
242
+ let raw;
243
+ try {
244
+ raw = fs.readFileSync(path.resolve(process.cwd(), patchFile), 'utf8');
245
+ } catch (err) {
246
+ return { ok: false, code: 'patch_file_unreadable', detail: { reason: err.message } };
247
+ }
248
+ // A bare patch file is still parsed through the SAME reader the task path
249
+ // uses, so the local shortcut can never accept a shape the real path rejects.
250
+ return proposals.parseProposalBlock(raw.trim().startsWith('{')
251
+ ? ['```' + proposals.FENCE, raw.trim(), '```'].join('\n')
252
+ : raw);
253
+ }
254
+ const res = await apiCall('GET', `/api/gds/tasks/${taskId}`);
255
+ if (!res.ok) {
256
+ return { ok: false, code: 'task_unreadable', detail: { task: taskId, status: res.status } };
257
+ }
258
+ const task = (res.data && res.data.task) || {};
259
+ const parsed = proposals.parseProposalBlock(task.description || '');
260
+ if (!parsed.ok) return parsed;
261
+ return { ok: true, patch: parsed.patch, task };
262
+ }
263
+
264
+ async function main() {
265
+ let taskId = arg('--task');
266
+ const patchFile = arg('--patch-file');
267
+ const dryRun = hasFlag('--dry-run');
268
+
269
+ if (!taskId && !patchFile) {
270
+ const held = await claimedTaskId();
271
+ if (!held.ok) return cliExit(refuse(held));
272
+ taskId = held.taskId;
273
+ console.log(`copy-apply: the task you are holding — #${taskId}`);
274
+ }
275
+
276
+ const loaded = await loadPatch({ taskId, patchFile });
277
+ if (!loaded.ok) return cliExit(refuse(loaded));
278
+ const { patch } = loaded;
279
+
280
+ const guard = checkPatchPath(patch);
281
+ if (!guard.ok) return cliExit(refuse(guard));
282
+
283
+ let source;
284
+ try {
285
+ source = fs.readFileSync(guard.abs, 'utf8');
286
+ } catch (err) {
287
+ return cliExit(refuse({ code: 'file_unreadable', detail: { file: guard.rel, reason: err.message } }));
288
+ }
289
+
290
+ const applied = applyPatchToSource(source, guard.rel, patch);
291
+ if (!applied.ok) return cliExit(refuse(applied));
292
+
293
+ console.log(`copy-apply: ${guard.rel}:${applied.line} (${patch.origin})`);
294
+ console.log(` - ${applied.before}`);
295
+ console.log(` + ${applied.after}`);
296
+
297
+ if (dryRun) {
298
+ console.log('copy-apply: --dry-run, nothing written.');
299
+ return cliExit(0);
300
+ }
301
+
302
+ fs.writeFileSync(guard.abs, applied.source);
303
+
304
+ // Regenerate the inventory in the same breath. The registry is a committed
305
+ // artifact whose CI check fails on drift, so an applier that edited a surface
306
+ // and left the registry describing the old tree would hand the builder a
307
+ // red build and a puzzle. It also gives us the verification below.
308
+ const { reg } = ci.write();
309
+
310
+ // VERIFY OUR OWN WORK, because a copy change that lands on the wrong characters
311
+ // is the failure this whole command is shaped to avoid — and the registry we
312
+ // just rebuilt is an independent read of the file we just wrote.
313
+ const nowThere = reg.entries.some((e) => e.file === guard.rel && e.text === patch.proposed);
314
+ if (!nowThere) {
315
+ console.error('copy-apply: WROTE THE FILE, but the regenerated registry does not contain the new wording at that path.');
316
+ console.error(' The edit landed somewhere the inventory does not read as copy. Review `git diff` before doing anything else.');
317
+ return cliExit(3);
318
+ }
319
+
320
+ console.log(`copy-apply: applied, registry regenerated (${reg.counts.strings} strings).`);
321
+ console.log(' Review with `git diff`, then ship this task the normal way.');
322
+ return cliExit(0);
323
+ }
324
+
325
+ if (require.main === module) {
326
+ main().catch((err) => {
327
+ console.error(`copy-apply: ${err && err.message ? err.message : err}`);
328
+ process.exitCode = 1;
329
+ });
330
+ }
331
+
332
+ module.exports = {
333
+ surfaceRootFor,
334
+ checkPatchPath,
335
+ locateOccurrence,
336
+ reinjectInterpolations,
337
+ applyPatchToSource,
338
+ };
@@ -132,6 +132,28 @@ function looksLikeCopy(raw) {
132
132
  return true;
133
133
  }
134
134
 
135
+ // THE SPAN: where a row's text physically SITS in the file, as [start, end)
136
+ // character offsets. Every extractor below records one alongside the text.
137
+ //
138
+ // It is deliberately NOT part of the committed registry — buildRegistry drops it
139
+ // (see the note there). A committed offset would go stale on the next edit to an
140
+ // unrelated line of the same file, and the registry's whole point is an id that
141
+ // survives movement. The span is a LIVE fact, recomputed from the tree by whoever
142
+ // needs it: today that is scripts/gds/copy-apply.js (R03 of goal 1000074), which
143
+ // re-runs this extractor against the working tree and uses the span to replace
144
+ // exactly the characters a string occupies and nothing else. Deriving it here,
145
+ // beside the regex that captured the text, is what keeps the applier from owning
146
+ // a second, drifting copy of these matching rules.
147
+ //
148
+ // The span covers the TRIMMED text — a row's `text` is whitespace-collapsed and
149
+ // trimmed, so the indentation around a multi-line literal belongs to the file's
150
+ // formatting, not to the copy, and an applier must leave it alone.
151
+ function trimmedSpan(group, groupStart) {
152
+ const lead = group.length - group.replace(/^\s+/, '').length;
153
+ const trail = group.length - group.replace(/\s+$/, '').length;
154
+ return { start: groupStart + lead, end: groupStart + group.length - trail };
155
+ }
156
+
135
157
  // Strip <script>/<style> bodies so their contents are not read as page text.
136
158
  // Replaced with spaces of equal length so every line number stays truthful.
137
159
  function stripEmbedded(html) {
@@ -151,7 +173,8 @@ function fromHtml({ source, rel }) {
151
173
  const textRe = />([^<>]+)</g;
152
174
  while ((m = textRe.exec(cleaned)) !== null) {
153
175
  if (!looksLikeCopy(m[1])) continue;
154
- out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-text', confidence: 'certain' });
176
+ // The captured group opens one character past the '>' that anchored the match.
177
+ out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-text', confidence: 'certain', ...trimmedSpan(m[1], m.index + 1) });
155
178
  }
156
179
 
157
180
  // 2. copy-bearing attributes.
@@ -159,14 +182,19 @@ function fromHtml({ source, rel }) {
159
182
  re.lastIndex = 0;
160
183
  while ((m = re.exec(cleaned)) !== null) {
161
184
  if (!looksLikeCopy(m[1])) continue;
162
- out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: `html-attr:${attr}`, confidence: 'certain' });
185
+ // Every COPY_ATTR_RE ends with the closing quote, so the group's last
186
+ // character sits exactly one before the match's end. DERIVED, never
187
+ // searched for — indexOf would land on an earlier copy of the same text.
188
+ const attrStart = m.index + m[0].length - 1 - m[1].length;
189
+ out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: `html-attr:${attr}`, confidence: 'certain', ...trimmedSpan(m[1], attrStart) });
163
190
  }
164
191
  }
165
192
 
166
193
  // 3. <title> is the page's own name — always copy.
167
194
  const titleRe = /<title>([^<]{2,200})<\/title>/gi;
168
195
  while ((m = titleRe.exec(source)) !== null) {
169
- out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain' });
196
+ const titleStart = m.index + '<title>'.length;
197
+ out.push({ text: m[1].replace(/\s+/g, ' ').trim(), file: rel, line: lineAt(starts, m.index), origin: 'html-title', confidence: 'certain', ...trimmedSpan(m[1], titleStart) });
170
198
  }
171
199
  return out;
172
200
  }
@@ -198,24 +226,75 @@ function unescapeLiteral(raw) {
198
226
  return String(raw).replace(/\\(['"`\\])/g, '$1');
199
227
  }
200
228
 
201
- // A literal that carries markup: extract what a person reads out of it.
229
+ // Can offsets into this match's BODY be read as offsets into the file?
230
+ //
231
+ // A template literal (group 1) is used raw, so yes. A quoted literal is
232
+ // `unescapeLiteral`d first, and that SHIFTS every character after a backslash —
233
+ // so yes only when the unescape changed nothing. The alternative, an
234
+ // escape-aware index map, would buy spans for a handful of quoted-markup rows
235
+ // and add a second place for the escaping rules to be wrong. A row with no span
236
+ // is refused BY NAME in copy-apply.js instead, which is the honest trade.
237
+ function literalIsUnshifted(m) {
238
+ if (m[1] !== undefined) return true;
239
+ const raw = m[2] ?? m[3];
240
+ return raw === undefined ? false : unescapeLiteral(raw) === raw;
241
+ }
242
+
243
+ // Interpolations are replaced by a fixed-width PLACEHOLDER before matching, so a
244
+ // position in the holed string is NOT a position in the source. This records the
245
+ // literal runs between the holes so one can be converted back to the other —
246
+ // which is what lets a markup row carry a source span (see `trimmedSpan`).
247
+ //
248
+ // The conversion is only ever asked for at a capture BOUNDARY, and every boundary
249
+ // sits on a real character: a text node's group opens just past a '>' and closes
250
+ // just before a '<', and neither can appear inside `{…}`. A boundary that lands
251
+ // where a hole meets the next run resolves to the character after the closing
252
+ // '}' of the interpolation, which is the right place to cut.
253
+ function holeMap(body) {
254
+ const re = /\$\{[^{}]*\}/g;
255
+ const runs = [];
256
+ let withHoles = '';
257
+ let last = 0;
258
+ let m;
259
+ while ((m = re.exec(body)) !== null) {
260
+ const literal = body.slice(last, m.index);
261
+ runs.push({ holed: withHoles.length, raw: last, length: literal.length });
262
+ withHoles += literal + PLACEHOLDER;
263
+ last = m.index + m[0].length;
264
+ }
265
+ const tail = body.slice(last);
266
+ runs.push({ holed: withHoles.length, raw: last, length: tail.length });
267
+ return { withHoles: withHoles + tail, runs };
268
+ }
269
+
270
+ function holedToRaw(runs, at) {
271
+ for (const run of runs) {
272
+ if (at >= run.holed && at <= run.holed + run.length) return run.raw + (at - run.holed);
273
+ }
274
+ return null; // only reachable for a position strictly inside a placeholder
275
+ }
276
+
277
+ // A literal that carries markup: extract what a person reads out of it, each row
278
+ // carrying [start, end) offsets into the RAW body it was found in.
202
279
  function copyFromMarkupLiteral(body) {
203
- const withHoles = body.replace(/\$\{[^{}]*\}/g, PLACEHOLDER);
280
+ const { withHoles, runs } = holeMap(body);
204
281
  const out = [];
282
+ const emit = (group, groupStart) => {
283
+ const t = group.replace(/\s+/g, ' ').trim();
284
+ if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) return;
285
+ const span = trimmedSpan(group, groupStart);
286
+ const start = holedToRaw(runs, span.start);
287
+ const end = holedToRaw(runs, span.end);
288
+ // A span that cannot be mapped back is reported as text with no span rather
289
+ // than as a guess; the applier refuses such a row by name.
290
+ out.push(start === null || end === null ? { text: t } : { text: t, start, end });
291
+ };
205
292
  let m;
206
293
  const textRe = />([^<>]+)</g;
207
- while ((m = textRe.exec(withHoles)) !== null) {
208
- const t = m[1].replace(/\s+/g, ' ').trim();
209
- if (t === PLACEHOLDER || !looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
210
- out.push(t);
211
- }
294
+ while ((m = textRe.exec(withHoles)) !== null) emit(m[1], m.index + 1);
212
295
  for (const { re } of COPY_ATTR_RES) {
213
296
  re.lastIndex = 0;
214
- while ((m = re.exec(withHoles)) !== null) {
215
- const t = m[1].replace(/\s+/g, ' ').trim();
216
- if (!looksLikeCopy(t.replace(/\{…\}/g, 'x'))) continue;
217
- out.push(t);
218
- }
297
+ while ((m = re.exec(withHoles)) !== null) emit(m[1], m.index + m[0].length - 1 - m[1].length);
219
298
  }
220
299
  return out;
221
300
  }
@@ -224,12 +303,12 @@ function fromJs({ source, rel }) {
224
303
  const out = [];
225
304
  const starts = lineIndex(source);
226
305
  const seen = new Set(); // (text, line) — one row per string per site
227
- const push = (text, index, origin) => {
306
+ const push = (text, index, origin, span = null) => {
228
307
  const line = lineAt(starts, index);
229
308
  const key = `${text}\u0000${line}\u0000${origin}`;
230
309
  if (seen.has(key)) return;
231
310
  seen.add(key);
232
- out.push({ text, file: rel, line, origin, confidence: 'certain' });
311
+ out.push({ text, file: rel, line, origin, confidence: 'certain', ...(span || {}) });
233
312
  };
234
313
 
235
314
  let m;
@@ -238,19 +317,31 @@ function fromJs({ source, rel }) {
238
317
  while ((m = JS_LITERALS.exec(source)) !== null) {
239
318
  const body = m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '');
240
319
  if (!body || !/<[a-zA-Z/]/.test(body) || !/>/.test(body)) continue;
241
- for (const t of copyFromMarkupLiteral(body)) push(t, m.index, 'js-markup');
242
- }
243
- // 2. strings shown directly to a person.
244
- UI_CALL.lastIndex = 0;
245
- while ((m = UI_CALL.exec(source)) !== null) {
246
- const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
247
- if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-notice');
248
- }
249
- TEXT_ASSIGN.lastIndex = 0;
250
- while ((m = TEXT_ASSIGN.exec(source)) !== null) {
251
- const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
252
- if (body && looksLikeCopy(body.replace(/\{…\}/g, 'x'))) push(body, m.index, 'js-text-assign');
320
+ // Each alternative opens with exactly one delimiter, so the body starts one
321
+ // character past the match.
322
+ const mapped = literalIsUnshifted(m);
323
+ for (const f of copyFromMarkupLiteral(body)) {
324
+ const span = mapped && f.start !== undefined
325
+ ? { start: m.index + 1 + f.start, end: m.index + 1 + f.end }
326
+ : null;
327
+ push(f.text, m.index, 'js-markup', span);
328
+ }
253
329
  }
330
+ // 2. strings shown directly to a person. Both of these normalize the WHOLE
331
+ // literal into the row's text, so the span is the whole body: the delimiter
332
+ // closes the match, so the body ends one character before the match does.
333
+ const wholeLiteral = (re, origin) => {
334
+ re.lastIndex = 0;
335
+ while ((m = re.exec(source)) !== null) {
336
+ const rawBody = m[1] ?? m[2] ?? m[3] ?? '';
337
+ const body = (m[1] ?? unescapeLiteral(m[2] ?? m[3] ?? '')).replace(/\$\{[^{}]*\}/g, PLACEHOLDER).replace(/\s+/g, ' ').trim();
338
+ if (!body || !looksLikeCopy(body.replace(/\{…\}/g, 'x'))) continue;
339
+ const bodyStart = m.index + m[0].length - 1 - rawBody.length;
340
+ push(body, m.index, origin, literalIsUnshifted(m) ? trimmedSpan(rawBody, bodyStart) : null);
341
+ }
342
+ };
343
+ wholeLiteral(UI_CALL, 'js-notice');
344
+ wholeLiteral(TEXT_ASSIGN, 'js-text-assign');
254
345
  return out;
255
346
  }
256
347
 
@@ -276,7 +367,14 @@ function buildRegistry() {
276
367
  ? fromHtml({ source, rel })
277
368
  : fromJs({ source, rel });
278
369
  for (const f of found) {
279
- entries.push({ id: stableId(surface.id, f.text), surface: surface.id, ...f });
370
+ // THE SPAN IS DROPPED HERE, DELIBERATELY. Every extractor records where a
371
+ // string physically sits (see `trimmedSpan`), and none of it is committed:
372
+ // an offset goes stale the moment anything above it in the file changes,
373
+ // so a registry carrying one would be wrong far more often than the id —
374
+ // which is designed to survive exactly that movement — ever is. The span
375
+ // is recomputed from the tree by the applier, at the moment it edits.
376
+ const { start, end, ...row } = f;
377
+ entries.push({ id: stableId(surface.id, f.text), surface: surface.id, ...row });
280
378
  count += 1;
281
379
  }
282
380
  }
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.491'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.493'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -158,13 +158,32 @@ test('the filters and the search are the kit\'s bar; the duplicates are paged th
158
158
  assert.match(read('copy-desk.html'), /src="\/builders\/hall-kit\.js/, 'the page loads the kit');
159
159
  });
160
160
 
161
- test('the two dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
161
+ test('the three dialogs are the family modal on a native <dialog>, with the number input in a field', () => {
162
162
  const html = read('copy-desk.html');
163
- assert.equal((html.match(/<dialog id="cd-(close-)?dialog" class="ov-modal cd-dialog">/g) || []).length, 2);
163
+ // Three since task 1003114 (R03): flag, close, and the proposal composer.
164
+ assert.equal((html.match(/<dialog id="cd-(close-dialog|dialog|propose-dialog)" class="ov-modal cd-dialog">/g) || []).length, 3);
164
165
  assert.match(html, /<div class="ov-field ov-field--wide">\s*<label class="ov-field__l" for="cd-close-task">[^<]*<\/label>\s*<input type="number" id="cd-close-task"/, 'the number input sits in the family field, which restates the 30px pill');
165
166
  assert.ok(!/<label[^>]*>[^<]*<span class="cd-dialog__hint"/.test(html), 'no hint lives inside a label (the label voice would uppercase it)');
166
- assert.equal((html.match(/class="ov-form__why"/g) || []).length, 3, 'the three hints are the form\'s own explainer line');
167
+ assert.equal((html.match(/class="ov-form__why"/g) || []).length, 4, 'the four hints are the form\'s own explainer line');
167
168
  assert.match(html, /class="btn-accent" id="cd-submit"/, 'the accent door files the flag');
169
+ assert.match(html, /class="btn-accent" id="cd-propose-submit"/, 'and the proposal composer has its own');
170
+ });
171
+
172
+ test('the proposal composer is prefilled, and says what filing it does and does not do', () => {
173
+ const html = read('copy-desk.html');
174
+ const js = read('copy-desk.js');
175
+ // The composer opens with the CURRENT wording in the box — an artist edits a
176
+ // sentence rather than retyping it out of the quote above, which is how a
177
+ // proposal accidentally changes a word nobody meant to change.
178
+ assert.match(js, /\$\('#cd-propose-text'\)\.value = t\.text;/, 'the textarea opens prefilled with the current wording');
179
+ // The placeholder rule is shown only when the sentence has holes, because for
180
+ // most strings it is noise and for a few it is the reason the change refuses.
181
+ assert.match(html, /id="cd-propose-holes" hidden/, 'the placeholder rule starts hidden');
182
+ assert.match(js, /hint\.hidden = n === 0;/, 'and is shown only for a sentence with holes');
183
+ // NO LIVE CMS, said to the person in the dialog rather than only in an ADR.
184
+ assert.match(html, /Nothing changes yet\. This files a task carrying your wording/,
185
+ 'the composer tells the artist their words do not reach a reader until a task ships');
186
+ assert.match(js, /copy-desk\/proposals/, 'and it posts to the proposal route');
168
187
  });
169
188
 
170
189
  test('the copy desk renders MORE facts per row than any other page, and lost none', () => {
@@ -205,10 +224,11 @@ test('the harness fixtures exist and carry the shapes the pages read', () => {
205
224
 
206
225
  test('the states files walk the new states', () => {
207
226
  const cd = JSON.parse(read('copy-desk.states.json'));
208
- assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer']);
227
+ assert.deepEqual(Object.keys(cd.states), ['queue', 'out', 'search', 'composer', 'closer', 'proposer']);
209
228
  assert.deepEqual(cd.states.search.actions[1], ['fill', '#cd-search', 'save']);
210
229
  assert.deepEqual(cd.states.composer.actions[1], ['clickIfVisible', '.cd-flag-btn'], 'clickIfVisible: against the kit\'s stub the button does not exist');
211
230
  assert.deepEqual(cd.states.closer.actions[1], ['clickIfVisible', '.cd-close-btn']);
231
+ assert.deepEqual(cd.states.proposer.actions[1], ['clickIfVisible', '.cd-propose-btn'], 'task 1003114: the proposal composer is a measured state, not one looked at once');
212
232
  const cb = JSON.parse(read('collab.states.json'));
213
233
  assert.deepEqual(cb.states.board.expect.visible, ['#cb-for-me']);
214
234
  });