@bongos/core 1.19.1063 → 1.19.1064

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 (37) hide show
  1. package/.bongos-core.json +72 -37
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +6 -0
  6. package/clients/bongos-client/index.cjs +6 -0
  7. package/clients/bongos-client/index.d.ts +10 -0
  8. package/clients/bongos-client/index.mjs +6 -0
  9. package/docs/api/openapi.json +258 -3
  10. package/docs/api-reference.md +5 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +2 -0
  13. package/modules/lifecycle/db-grade.js +16 -0
  14. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  15. package/modules/lifecycle/page-tweak-hold.js +74 -0
  16. package/modules/lifecycle/page-tweak-reads.js +33 -1
  17. package/modules/lifecycle/routes/tasks.js +4 -2
  18. package/modules/lifecycle/routes/visuals.js +82 -0
  19. package/modules/lifecycle/task-visual-db.js +40 -1
  20. package/modules/lifecycle/task-visuals.js +24 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +6 -0
  24. package/scripts/gds/copy-apply.js +277 -1
  25. package/scripts/gds/page-reader.js +2 -0
  26. package/scripts/gds/ship-finish.js +27 -2
  27. package/scripts/gds/ship-flow.js +17 -1
  28. package/scripts/gds/ship-land.js +30 -7
  29. package/scripts/gds/ship-merge.js +43 -15
  30. package/scripts/gds/strand-watch.js +41 -1
  31. package/scripts/gds/task.js +23 -5
  32. package/scripts/gds/tweak-renders.js +200 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/page_tweak_hold.mjs +265 -0
  35. package/tests/publish_status_branch.mjs +23 -0
  36. package/tests/task_visual_slots.mjs +161 -0
  37. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,265 @@
1
+ // tests/tweak_batch_apply.mjs — copy-apply.js --batch lands a whole page round
2
+ // (task 1004322 / BV2.TW11; ADR 0341 D4).
3
+ //
4
+ // The done-when: "/tweak applies a multi-line page batch ... a refused line is
5
+ // named, not skipped." Two layers:
6
+ // 1. the pure core (applyBatchToSources) over fixture sources and a registry
7
+ // built by the REAL extractors, so a line is matched exactly the way the
8
+ // inventory reads it;
9
+ // 2. the whole command (batchMain) on a FIXTURE ROUND: a page-tweak task read
10
+ // from a fake API, its files from an in-memory tree, the registry
11
+ // regenerated by the real extractors over the files it wrote, and the
12
+ // page-tweak-applied block PATCHed back and parsed by the one reader.
13
+ // No DB, no network, no browser (the page re-read is the one stubbed step; the
14
+ // real re-read is page-reader.js --stale, covered by tests/page_reader.mjs).
15
+ //
16
+ // Run: node tests/tweak_batch_apply.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { makeRunner } from './helpers.mjs';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ci = require('../scripts/gds/copy-inventory.js');
26
+ const pages = require('../modules/copy-desk/pages.js');
27
+ const apply = require('../scripts/gds/copy-apply.js');
28
+
29
+ const { test, summary } = makeRunner();
30
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
31
+
32
+ const HTML = 'modules/public-landing/public/fixture-tweak.html';
33
+ const JS = 'modules/hall-ui/public/fixture-tweak.js';
34
+ const OTHER = 'modules/public-landing/public/fixture-other.html';
35
+ const SURFACE_OF = { [HTML]: 'landing', [JS]: 'builders-hall', [OTHER]: 'landing' };
36
+
37
+ const TREE = () => ({
38
+ [HTML]: `<!doctype html>
39
+ <html><body>
40
+ <main>
41
+ <h1>Welcome to the studio</h1>
42
+ <p>Take a page and rewrite every word of it.</p>
43
+ <p>Every word counts, and every word is yours.</p>
44
+ <button>Take next</button>
45
+ </main>
46
+ </body></html>
47
+ `,
48
+ [JS]: "'use strict';\nfunction render(el, n) {\n el.innerHTML = `<p>You have ${n} pages waiting for review</p>`;\n}\n",
49
+ [OTHER]: '<!doctype html>\n<html><body>\n<p>Take next</p>\n</body></html>\n',
50
+ });
51
+
52
+ // The registry the inventory would write over a tree: the real extractors, the
53
+ // real stable id, the span dropped exactly as buildRegistry drops it.
54
+ function registryOver(tree) {
55
+ const entries = [];
56
+ for (const [rel, source] of Object.entries(tree)) {
57
+ const rows = rel.endsWith('.html') ? ci.fromHtml({ source, rel }) : ci.fromJs({ source, rel });
58
+ for (const { start, end, ...row } of rows) entries.push({ id: ci.stableId(SURFACE_OF[rel], row.text), surface: SURFACE_OF[rel], ...row });
59
+ }
60
+ return { entries, counts: { strings: entries.length } };
61
+ }
62
+
63
+ const REG = registryOver(TREE());
64
+ const row = (text, file) => REG.entries.find((e) => e.text === text && (!file || e.file === file));
65
+ const line = (key, before, after, file, extra = {}) => {
66
+ const r = row(extra.registryText || before, file);
67
+ return { key, section: 'main', before, after, placement: 'placed', file: r ? r.file : file, line: r ? r.line : 1, string_id: r ? r.id : 'ffffffffffff', ...extra.override };
68
+ };
69
+
70
+ const BATCH = () => ({
71
+ page_id: 'landing:fixture',
72
+ reading_hash: 'r-before',
73
+ submitted_at: '2026-09-28T00:00:00Z',
74
+ lines: [
75
+ line('L0001', 'Welcome to the studio', 'Welcome to your studio', HTML),
76
+ line('L0002', 'Take a page and rewrite every word of it.', 'Take a page and make every word of it yours.', HTML),
77
+ line('L0003', 'You have {…} pages waiting for review', 'There are {…} pages waiting for you', JS),
78
+ line('L0004', 'Take next', 'Take the next page', HTML),
79
+ ],
80
+ });
81
+
82
+ const readerOf = (tree) => (rel) => { if (!(rel in tree)) throw new Error(`ENOENT ${rel}`); return tree[rel]; };
83
+
84
+ await test('a multi-line batch applies every line, across files, holes put back', () => {
85
+ const tree = TREE();
86
+ const out = apply.applyBatchToSources({ batch: BATCH(), registry: REG, readSource: readerOf(tree) });
87
+ assert.deepEqual(out.refused, []);
88
+ assert.deepEqual(out.applied.map((a) => a.key), ['L0001', 'L0002', 'L0003', 'L0004'], 'reported in PAGE order');
89
+ const html = out.sources.get(HTML);
90
+ assert.match(html, /<h1>Welcome to your studio<\/h1>/);
91
+ assert.match(html, /<p>Take a page and make every word of it yours\.<\/p>/);
92
+ assert.match(html, /<button>Take the next page<\/button>/);
93
+ assert.match(html, /Every word counts, and every word is yours\./, 'an untouched line is untouched');
94
+ assert.equal(out.sources.get(JS), "'use strict';\nfunction render(el, n) {\n el.innerHTML = `<p>There are ${n} pages waiting for you</p>`;\n}\n", 'the ${n} expression is re-injected, not the {…} placeholder');
95
+ assert.ok(!out.sources.has(OTHER), 'the same sentence in ANOTHER file is left alone: the line names its own file');
96
+ });
97
+
98
+ await test('a refused line is NAMED, with its code, and the rest of the batch still lands', () => {
99
+ const b = BATCH();
100
+ b.lines.push(
101
+ // the page changed after submit: its string is gone from the registry
102
+ { key: 'L0005', section: 'main', before: 'A sentence no page shows', after: 'Still not shown', placement: 'placed', file: HTML, line: 9, string_id: '000000000000' },
103
+ // the rewrite drops the value the page drops in
104
+ line('L0006', 'You have {…} pages waiting for review', 'Lots of pages are waiting', JS),
105
+ // a fragment that appears twice inside its string
106
+ line('L0007', 'word', 'phrase', HTML, { registryText: 'Every word counts, and every word is yours.' }),
107
+ // a fragment that is not inside its string at all
108
+ line('L0008', 'nowhere in it', 'still nowhere', HTML, { registryText: 'Every word counts, and every word is yours.' }),
109
+ );
110
+ // and a registry row that points outside every surface
111
+ const reg = { entries: [...REG.entries, { id: 'aaaaaaaaaaaa', surface: 'landing', text: 'Secret', file: 'config/secret.html', line: 1, origin: 'html-text' }] };
112
+ b.lines.push({ key: 'L0009', section: 'main', before: 'Secret', after: 'Public', placement: 'placed', file: 'config/secret.html', line: 1, string_id: 'aaaaaaaaaaaa' });
113
+ const out = apply.applyBatchToSources({ batch: b, registry: reg, readSource: readerOf(TREE()) });
114
+ assert.deepEqual(out.refused.map((x) => [x.key, x.code]), [
115
+ ['L0005', 'target_gone'],
116
+ ['L0006', 'placeholder_mismatch'],
117
+ ['L0007', 'ambiguous_target'],
118
+ ['L0008', 'no_exact_span'],
119
+ ['L0009', 'path_outside_surface'],
120
+ ]);
121
+ for (const x of out.refused) assert.ok(apply.REFUSAL_CODES.includes(x.code), `${x.code} is a named code`);
122
+ assert.equal(out.applied.length, 4, 'the four good lines still applied');
123
+ assert.equal(out.applied.length + out.refused.length, b.lines.length, 'NO line is skipped silently: every line is applied or refused');
124
+ });
125
+
126
+ await test('two lines inside ONE string compose: the second is resolved against what the first left', () => {
127
+ const b = { page_id: 'landing:fixture', lines: [
128
+ line('L0001', 'Every word counts', 'Each word matters', HTML, { registryText: 'Every word counts, and every word is yours.' }),
129
+ line('L0002', 'is yours', 'belongs to you', HTML, { registryText: 'Every word counts, and every word is yours.' }),
130
+ ] };
131
+ const out = apply.applyBatchToSources({ batch: b, registry: REG, readSource: readerOf(TREE()) });
132
+ assert.deepEqual(out.refused, []);
133
+ assert.match(out.sources.get(HTML), /<p>Each word matters, and every word belongs to you\.<\/p>/);
134
+ });
135
+
136
+ await test('a string id shared by two files resolves to the row in the line\'s own file', () => {
137
+ const r = apply.registryRowFor({ string_id: row('Take next', OTHER).id, file: OTHER, line: 3 }, new Map([[row('Take next', OTHER).id, REG.entries.filter((e) => e.text === 'Take next')]]));
138
+ assert.equal(r.file, OTHER);
139
+ });
140
+
141
+ await test('the applied block leaves shared lines out of lines_after and carries each refusal', () => {
142
+ const block = apply.appliedBlockFor({
143
+ pageId: 'landing:fixture',
144
+ appliedAt: '2026-09-28T01:00:00Z',
145
+ readingPage: { reading_hash: 'r-after', lines: [
146
+ { key: 'L0001', text: 'Skip to content', placement: 'shared' },
147
+ { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' },
148
+ { key: 'L0003', text: 'Something unplaced', placement: 'unplaced' },
149
+ ] },
150
+ refused: [{ key: 'L0005', code: 'target_gone', detail: { x: 1 } }],
151
+ });
152
+ assert.deepEqual(block.lines_after, ['Welcome to your studio', 'Something unplaced']);
153
+ assert.deepEqual(block.refused, [{ key: 'L0005', code: 'target_gone' }], 'key and code only');
154
+ const parsed = pages.parseAppliedBlock(pages.composeAppliedBlock(block));
155
+ assert.equal(parsed.ok, true);
156
+ assert.equal(parsed.applied.reading_hash_after, 'r-after');
157
+ });
158
+
159
+ // ---- the whole command on a fixture round ---------------------------------
160
+
161
+ function fixtureRound(batch) {
162
+ const page = { id: batch.page_id, title: 'Fixture' };
163
+ return [pages.roundTaskBody(page, 1), '', pages.composeBatchBlock({ ...batch, unplaced_tasks: [] })].join('\n');
164
+ }
165
+
166
+ function harness({ batch, source = 'page-tweak', registryOverride = null, rereadOk = true } = {}) {
167
+ const tree = TREE();
168
+ const abs = (rel) => path.join(REPO_ROOT, rel);
169
+ const files = new Map(Object.entries(tree).map(([rel, s]) => [abs(rel), s]));
170
+ const writes = [];
171
+ const calls = [];
172
+ const task = { id: '5001', source, description: fixtureRound(batch) };
173
+ const deps = {
174
+ log: () => {},
175
+ readFile: (p) => { if (!files.has(p)) throw new Error(`ENOENT ${p}`); return files.get(p); },
176
+ writeFile: (p, s) => { writes.push(p); files.set(p, s); },
177
+ registry: REG,
178
+ regenerateRegistry: () => ({ reg: registryOverride || registryOver(Object.fromEntries(Object.keys(tree).map((rel) => [rel, files.get(abs(rel))]))) }),
179
+ reread: () => (rereadOk ? { ok: true } : { ok: false, code: 'no_browser' }),
180
+ readingFor: () => ({ reading_hash: 'r-after', lines: [{ key: 'L0001', text: 'Skip to content', placement: 'shared' }, { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' }] }),
181
+ api: async (method, url, body) => {
182
+ calls.push({ method, url, body });
183
+ if (method === 'GET') return { ok: true, status: 200, data: { task } };
184
+ if (method === 'PATCH') { task.description = body.description; return { ok: true, status: 200, data: { task } }; }
185
+ return { ok: false, status: 405 };
186
+ },
187
+ };
188
+ return { deps, files, writes, calls, task, abs };
189
+ }
190
+
191
+ await test('END TO END: a fixture round is applied, its files written, and the applied block recorded on the task', async () => {
192
+ const b = BATCH();
193
+ b.lines.push({ key: 'L0005', section: 'main', before: 'A sentence no page shows', after: 'Still not shown', placement: 'placed', file: HTML, line: 9, string_id: '000000000000' });
194
+ const h = harness({ batch: b });
195
+ const code = await apply.batchMain({ taskId: '5001', now: () => '2026-09-28T02:00:00Z' }, h.deps);
196
+ assert.equal(code, 0);
197
+ assert.match(h.files.get(h.abs(HTML)), /Welcome to your studio/);
198
+ assert.match(h.files.get(h.abs(JS)), /There are \$\{n\} pages waiting for you/);
199
+ assert.deepEqual(h.writes.sort(), [h.abs(HTML), h.abs(JS)].sort(), 'only the files the batch changed are written');
200
+ const patch = h.calls.find((c) => c.method === 'PATCH');
201
+ assert.ok(patch, 'the applied block is written back to the round');
202
+ assert.equal(patch.url, '/api/bongos/tasks/5001');
203
+ const applied = pages.parseAppliedBlock(patch.body.description);
204
+ assert.equal(applied.ok, true, 'one applied block, readable by the one reader');
205
+ assert.deepEqual(applied.applied.refused, [{ key: 'L0005', code: 'target_gone' }], 'the refused line is named on the task, not skipped');
206
+ assert.equal(applied.applied.reading_hash_after, 'r-after');
207
+ assert.deepEqual(applied.applied.lines_after, ['Welcome to your studio']);
208
+ assert.equal(pages.parseBatchBlock(patch.body.description).ok, true, 'the frozen batch is kept beside it');
209
+ const presence = pages.blockPresence(patch.body.description);
210
+ assert.equal(presence.applied, true);
211
+ assert.equal(presence.applied_at, '2026-09-28T02:00:00Z');
212
+ });
213
+
214
+ await test('a re-apply REPLACES the applied block rather than adding a second', async () => {
215
+ const h = harness({ batch: BATCH() });
216
+ await apply.batchMain({ taskId: '5001', now: () => 't1' }, h.deps);
217
+ const h2 = harness({ batch: BATCH() });
218
+ h2.task.description = h.task.description;
219
+ await apply.batchMain({ taskId: '5001', now: () => 't2' }, h2.deps);
220
+ assert.equal(pages.parseAppliedBlock(h2.task.description).ok, true, 'still exactly one applied block');
221
+ assert.equal(pages.parseAppliedBlock(h2.task.description).applied.applied_at, 't2');
222
+ });
223
+
224
+ await test('--dry-run writes no file and no task', async () => {
225
+ const h = harness({ batch: BATCH() });
226
+ const code = await apply.batchMain({ taskId: '5001', dryRun: true }, h.deps);
227
+ assert.equal(code, 0);
228
+ assert.deepEqual(h.writes, []);
229
+ assert.ok(!h.calls.some((c) => c.method === 'PATCH'));
230
+ });
231
+
232
+ await test('every line refused: exit 2, and the refusals are still recorded on the task', async () => {
233
+ const h = harness({ batch: { page_id: 'landing:fixture', reading_hash: 'r', submitted_at: 't', lines: [
234
+ { key: 'L0005', section: 'main', before: 'Gone', after: 'Still gone', placement: 'placed', file: HTML, line: 9, string_id: '000000000000' },
235
+ ] } });
236
+ const code = await apply.batchMain({ taskId: '5001' }, h.deps);
237
+ assert.equal(code, 2);
238
+ const patch = h.calls.find((c) => c.method === 'PATCH');
239
+ assert.deepEqual(pages.parseAppliedBlock(patch.body.description).applied.refused, [{ key: 'L0005', code: 'target_gone' }]);
240
+ });
241
+
242
+ await test('the regenerated registry not showing the new wording is a hard stop (exit 3), before any task write', async () => {
243
+ const h = harness({ batch: BATCH(), registryOverride: REG });
244
+ const code = await apply.batchMain({ taskId: '5001' }, h.deps);
245
+ assert.equal(code, 3);
246
+ assert.ok(!h.calls.some((c) => c.method === 'PATCH'));
247
+ });
248
+
249
+ await test('a page that cannot be re-read stops before the applied block (exit 4): no block from a stale reading', async () => {
250
+ const h = harness({ batch: BATCH(), rereadOk: false });
251
+ assert.equal(await apply.batchMain({ taskId: '5001' }, h.deps), 4);
252
+ assert.ok(!h.calls.some((c) => c.method === 'PATCH'));
253
+ });
254
+
255
+ await test('--batch refuses a task that is not a page tweak', async () => {
256
+ const h = harness({ batch: BATCH(), source: 'copy-proposal' });
257
+ const logged = [];
258
+ const origErr = console.error;
259
+ console.error = (m) => logged.push(String(m));
260
+ try { assert.equal(await apply.batchMain({ taskId: '5001' }, h.deps), 2); } finally { console.error = origErr; }
261
+ assert.ok(logged.some((m) => m.includes('not_a_page_tweak')));
262
+ assert.deepEqual(h.writes, []);
263
+ });
264
+
265
+ summary();