@bongos/core 1.19.1066 → 1.19.1067

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.
@@ -11,10 +11,10 @@
11
11
  // 3. it says where it came from
12
12
  // 4. it files at backlog, never ready
13
13
  //
14
- // The production table ships EMPTY (R04 writes the first rule), so the rules
15
- // below are the test's OWN declarations — which is also the point: a mechanism
16
- // proved by a rule its consumer has not written yet is proved against the
17
- // contract rather than against one example.
14
+ // The mechanism is proved by the test's OWN declarations, not a production
15
+ // rule — which is also the point: a mechanism proved against the contract
16
+ // rather than one example survives its rules coming and going (the artist
17
+ // review, R04's, was removed by TW14).
18
18
  //
19
19
  // DB-free: the runner takes its three database calls as injected functions.
20
20
 
@@ -185,7 +185,13 @@ await test('the production table is valid, and a duplicate key is refused', () =
185
185
  assert.ok(shape.some((p) => /body/.test(p)));
186
186
  });
187
187
 
188
- // --- the first production rule: the artist review (R04, task 1003115) --------
188
+ // --- the artist review is GONE: Tweak Mode replaced it (TW14, ADR 0341 D9) ----
189
+ //
190
+ // R04 (task 1003115) wrote `artist-review-on-ship`, which filed an artist review
191
+ // for every copy/visual ship. Task 1004325 removed it: a ship that changes a
192
+ // page's words writes NOTHING, and the page reads "changed since last tweak"
193
+ // because drift is derived (D8). tests/tweak_replaces_artist_review.mjs proves
194
+ // that second half on the whole path; these pin the first.
189
195
 
190
196
  const shippedCopy = (over = {}) => ({
191
197
  id: '7001', version_id: 'BONGOS-V1', goal_id: '1000074',
@@ -194,66 +200,59 @@ const shippedCopy = (over = {}) => ({
194
200
  ...over,
195
201
  });
196
202
 
197
- await test('the artist review is keyed and disciplined, and the table stays well-formed', () => {
198
- // This pinned CASCADES.length === 1 until task 1004090 appended the removal
199
- // proposal (BV2.R15). A count is the wrong pin for a table whose whole contract
200
- // is "extend by appending an entry" (ADR 0242) — it fails on the one change it
201
- // is meant to permit, and says nothing about WHICH rules are there.
202
- //
203
- // So it pins the rule by KEY instead, and pins the SET of keys separately below:
204
- // a rule vanishing and a rule being added are different events and should fail
205
- // differently.
206
- const r = cascade.CASCADES.find((x) => x.key === 'artist-review-on-ship');
207
- assert.ok(r, 'the artist review rule is gone from CASCADES');
208
- assert.equal(r.discipline, 'artist', 'the review is FOR the artist, whatever role shipped the work');
209
- assert.equal(r.kind, 'unclassified', 'a valid kind — not the invalid "chore" the spec used to default to');
210
- assert.deepEqual(cascade.validateCascades(), [], 'and the shipped table is well-formed');
203
+ // A test rule that records the ctx the runner handed it, so the runner's
204
+ // resolution of policy and changed files is still pinned with no production
205
+ // rule reading either.
206
+ const ctxProbe = () => {
207
+ const seen = [];
208
+ return {
209
+ seen,
210
+ rule: {
211
+ key: 'test-ctx-probe',
212
+ discipline: 'artist',
213
+ when: (t, ctx) => { seen.push(ctx); return false; },
214
+ title: () => 'never',
215
+ body: () => 'never',
216
+ },
217
+ };
218
+ };
219
+
220
+ await test('the artist review rule is gone from CASCADES, and the table stays well-formed', () => {
221
+ assert.equal(cascade.CASCADES.find((x) => x.key === 'artist-review-on-ship'), undefined,
222
+ 'Tweak Mode replaced the per-ship review (ADR 0341 D9)');
223
+ assert.equal(cascade.artistReviewBody, undefined, 'its body went with it');
224
+ assert.deepEqual(cascade.validateCascades(), [], 'the shipped table is well-formed');
211
225
  });
212
226
 
213
227
  await test('the production rule set is exactly what is declared', () => {
214
- // The other half of the pin above: adding a rule is a deliberate act, so it
215
- // should have to be recorded here. Duplicate keys would collide on source_ref
216
- // and silently suppress one another, which validateCascades already refuses.
217
- assert.deepEqual(
218
- cascade.CASCADES.map((r) => r.key).sort(),
219
- ['artist-review-on-ship', 'removal-proposed-on-ship'],
220
- );
228
+ // Adding a rule is a deliberate act, so it should have to be recorded here,
229
+ // and a rule vanishing fails here too. Duplicate keys would collide on
230
+ // source_ref and silently suppress one another, which validateCascades refuses.
231
+ assert.deepEqual(cascade.CASCADES.map((r) => r.key).sort(), ['removal-proposed-on-ship']);
232
+ });
233
+
234
+ await test('a copy/visual ship files NO artist review, at every artist-gate level', () => {
235
+ for (const ctx of [undefined, {}, { artistGate: 'strict' }, { artistGate: 'advisory' }, { artistGate: 'off' }, { artistGate: undefined }]) {
236
+ const { matched, errors } = cascade.cascadesFor(shippedCopy(), cascade.CASCADES, ctx);
237
+ assert.deepEqual(errors, []);
238
+ assert.equal(matched.length, 0, `nothing filed with ctx ${JSON.stringify(ctx)}`);
239
+ }
240
+ // Every shape that USED to file one: a copy proposal landing, an artist's own
241
+ // work, an unlabelled ship that changed a page file, and a truncated file list.
242
+ const shapes = [
243
+ [shippedCopy({ module_key: null, source: 'copy-proposal' }), {}],
244
+ [shippedCopy({ module_key: null, discipline: 'artist' }), {}],
245
+ [shippedCopy({ module_key: null }), { changedFiles: ['modules/hall-ui/public/studio.html'] }],
246
+ [shippedCopy({ module_key: null }), { changedFiles: [], changedFilesTruncated: true }],
247
+ ];
248
+ for (const [task, ctx] of shapes) {
249
+ assert.equal(cascade.cascadesFor(task, cascade.CASCADES, ctx).matched.length, 0, JSON.stringify(ctx));
250
+ }
221
251
  });
222
252
 
223
- await test('a copy/visual ship generates the two-step artist review', () => {
224
- const { matched } = cascade.cascadesFor(shippedCopy());
225
- assert.equal(matched.length, 1);
226
- const spec = matched[0].spec;
227
- assert.equal(spec.discipline, 'artist');
228
- assert.equal(spec.kind, 'unclassified');
229
- assert.equal(spec.status, 'backlog', 'a request that triage may dismiss — not claimable, not nagging');
230
- assert.match(spec.title, /7001/);
231
- // Both steps, and both routes of step 2, are in the body.
232
- //
233
- // STEP 1's label tracks ADR 0298 (task 1003937), and it has now turned twice.
234
- // It first read "REVIEW THE GRADER … coach it", which asked for something no
235
- // artist could do: the panel's four lenses read completion, code quality,
236
- // security and runtime cost, so there was no copy verdict to coach. It was
237
- // rewritten to ask for the RECORD instead, as an explicitly INTERIM wording,
238
- // pinned here so a well-meaning copy-edit could not restore the unfollowable
239
- // version without reading why.
240
- //
241
- // Task 1003977 shipped the advisory reader lens and the voice guide it waited
242
- // on (task 1003976), so the step is coaching again — and this pin moves with
243
- // it. The two must turn TOGETHER: the wording is the signal for which world
244
- // we are in, so a body that still said "no lens reads a sentence" after the
245
- // lens landed would send every artist looking for a counterpart that is
246
- // sitting right there in the grade record.
247
- assert.match(spec.description, /STEP 1 — COACH THE READER/);
248
- assert.match(spec.description, /docs\/voice\.md/, 'and names the written standard to coach against');
249
- assert.doesNotMatch(spec.description, /none of them reads a sentence/,
250
- 'the interim claim is gone — it stopped being true when the reader shipped');
251
- assert.match(spec.description, /ADR 0298/, 'the step names the decision that shaped it');
252
- assert.match(spec.description, /STEP 2 — REMAKE OR TWEAK/);
253
- assert.match(spec.description, /OUTSIDE the AI chat/);
254
- assert.match(spec.description, /WITH it/);
255
- assert.match(spec.description, /DISMISS this in triage/);
256
- assert.match(spec.description, /blocks nothing that shipped/, 'it says out loud that it is not a gate');
253
+ await test('hasCopyOrVisualWork STAYS, because the grader\'s reader lens is scoped by it', () => {
254
+ assert.equal(typeof cascade.hasCopyOrVisualWork, 'function');
255
+ assert.equal(cascade.hasCopyOrVisualWork(shippedCopy()), true, 'a hall-ui ship is still copy/visual work');
257
256
  });
258
257
 
259
258
  await test('"copy or visuals" is a NARROW predicate — three ways in, and a default of no', () => {
@@ -262,49 +261,33 @@ await test('"copy or visuals" is a NARROW predicate — three ways in, and a def
262
261
  assert.equal(H(shippedCopy({ module_key: 'art-pipeline' })), true);
263
262
  assert.equal(H(shippedCopy({ module_key: null, source: 'copy-proposal' })), true, 'a landed copy proposal');
264
263
  assert.equal(H(shippedCopy({ module_key: null, source: 'manual', discipline: 'artist' })), true, 'work an artist did');
265
- // The default is NO — a backend/infra ship is not an artist review.
264
+ // The default is NO — a backend/infra ship is not copy/visual work.
266
265
  assert.equal(H(shippedCopy({ module_key: 'lifecycle', discipline: 'engineer', source: 'manual' })), false);
267
266
  assert.equal(H(shippedCopy({ module_key: null, discipline: 'engineer', source: 'manual' })), false);
268
267
  assert.equal(H(null), false);
269
268
  });
270
269
 
271
- await test('THE ANTI-RECURSION GUARD: an artist review shipping does NOT generate a review of itself', () => {
272
- // The generated review is discipline:'artist' + source:'cascade'. Its own
273
- // predicate (discipline artist) would match — so without the source guard it
274
- // would cascade forever. This is the test that proves it is a single hop.
275
- const reviewTaskThatShipped = shippedCopy({
276
- discipline: 'artist', source: cascade.CASCADE_SOURCE, module_key: null, id: '7100',
270
+ await test('THE ANTI-RECURSION GUARD: a cascade-generated task shipping matches no production rule', () => {
271
+ // The removal rule's guard: even a cascade task whose ship deleted earlier
272
+ // work files nothing, so generation stays a single hop.
273
+ const cascaded = shippedCopy({ discipline: 'artist', source: cascade.CASCADE_SOURCE, module_key: null, id: '7100' });
274
+ const { matched } = cascade.cascadesFor(cascaded, cascade.CASCADES, {
275
+ removals: [{ taskId: '42', paths: ['a.js'] }],
277
276
  });
278
- const { matched } = cascade.cascadesFor(reviewTaskThatShipped);
279
- assert.equal(matched.length, 0, 'a cascade-generated task never triggers another cascade');
277
+ assert.equal(matched.length, 0);
280
278
  });
281
279
 
282
- await test('the review inherits the shipped task goal and version, not goal 1000074 by fiat', () => {
283
- // A copy ship in some other goal gets its review filed next to that work.
284
- const { matched } = cascade.cascadesFor(shippedCopy({ goal_id: '1000046', version_id: 'BONGOS-V1' }));
285
- assert.equal(matched[0].spec.goalId, 1000046);
286
- assert.equal(matched[0].spec.versionId, 'BONGOS-V1');
287
- });
280
+ // --- the runner still resolves its policy and the changed files --------------
288
281
 
289
- // --- the project's artist gate decides whether it is filed at all (ADR 0241) --
290
-
291
- await test("artist_gate 'off' files NO artist review — the one level at which the rule is silent", () => {
292
- assert.equal(cascade.cascadesFor(shippedCopy(), cascade.CASCADES, { artistGate: 'off' }).matched.length, 0);
293
- // The other two levels both FILE; what separates them is whether an open review
294
- // holds a deploy, which is not this file's business at all.
295
- assert.equal(cascade.cascadesFor(shippedCopy(), cascade.CASCADES, { artistGate: 'advisory' }).matched.length, 1);
296
- assert.equal(cascade.cascadesFor(shippedCopy(), cascade.CASCADES, { artistGate: 'strict' }).matched.length, 1);
297
- });
298
-
299
- await test('an UNREAD policy files — silence is never read as an opt-out', () => {
300
- // No context at all (an old caller), and an explicitly-undefined level (a reader
301
- // that threw) must both behave like the gate's default, which is 'strict'.
302
- assert.equal(cascade.cascadesFor(shippedCopy()).matched.length, 1);
303
- assert.equal(cascade.cascadesFor(shippedCopy(), cascade.CASCADES, {}).matched.length, 1);
304
- assert.equal(cascade.cascadesFor(shippedCopy(), cascade.CASCADES, { artistGate: undefined }).matched.length, 1);
305
- });
282
+ await test('the runner still hands the artist gate to the rules, and a reader that THROWS is reported', async () => {
283
+ const ok = ctxProbe();
284
+ await cascade.runCascadesForShip({ id: '7001' }, {
285
+ rules: [ok.rule], getTask: async () => shippedCopy(),
286
+ findTaskBySourceRef: async () => null, createTask: async () => ({ id: '1' }),
287
+ artistGateMode: () => 'advisory',
288
+ });
289
+ assert.equal(ok.seen[0].artistGate, 'advisory');
306
290
 
307
- await test('a gate reader that THROWS costs the run nothing — it is reported, and the review is still filed', async () => {
308
291
  const created = [];
309
292
  const report = await cascade.runCascadesForShip({ id: '7001' }, {
310
293
  getTask: async () => shippedCopy(),
@@ -312,24 +295,11 @@ await test('a gate reader that THROWS costs the run nothing — it is reported,
312
295
  createTask: async (spec) => { created.push(spec); return { id: '7002' }; },
313
296
  artistGateMode: () => { throw new Error('pack unreadable'); },
314
297
  });
315
- assert.equal(created.length, 1, 'an unreadable policy must not silently behave like off');
316
- assert.ok(report.errors.some((e) => /artistGateMode\(\) threw/.test(e)), 'and it says so');
317
- });
318
-
319
- await test("runCascadesForShip honours 'off' end to end — nothing is created, nothing errors", async () => {
320
- let creates = 0;
321
- const report = await cascade.runCascadesForShip({ id: '7001' }, {
322
- getTask: async () => shippedCopy(),
323
- findTaskBySourceRef: async () => null,
324
- createTask: async () => { creates++; return { id: '7002' }; },
325
- artistGateMode: () => 'off',
326
- });
327
- assert.equal(creates, 0);
328
- assert.deepEqual(report.created, []);
329
- assert.deepEqual(report.errors, [], 'a project that switched the gate off has no problem to report');
298
+ assert.equal(created.length, 0, 'and the production table files nothing either way');
299
+ assert.ok(report.errors.some((e) => /artistGateMode\(\) threw/.test(e)), 'the failed read says so');
330
300
  });
331
301
 
332
- // --- the review keys on the FILES a ship changed, not its label (task 1004201) --
302
+ // --- the changed files (task 1004201) ----------------------------------------
333
303
 
334
304
  await test('a page file changed under modules/*/public/ is visual work whatever the label says', () => {
335
305
  const H = cascade.hasCopyOrVisualWork;
@@ -358,67 +328,43 @@ await test('a backend file in a UI module is NOT visual work — page files only
358
328
  assert.equal(H(unlabelled, null), false);
359
329
  });
360
330
 
361
- await test('the ship runner resolves the changed files ONCE and the artist review files on them', async () => {
362
- const created = [];
331
+ await test('the ship runner resolves the changed files ONCE into ctx, and a page change files nothing', async () => {
332
+ const probe = ctxProbe();
363
333
  let asked = 0;
364
- const deps = {
365
- getTask: async () => shippedCopy({ module_key: null, discipline: 'engineer' }),
366
- findTaskBySourceRef: async () => null,
367
- createTask: async (spec) => { created.push(spec); return { id: String(9300 + created.length) }; },
368
- changedFiles: async () => { asked++; return { paths: ['modules/hall-ui/public/goal-forest-render.js'], truncated: false }; },
369
- };
370
- const report = await cascade.runCascadesForShip({ id: '7001' }, deps);
334
+ const changedFiles = async () => { asked++; return { paths: ['modules/hall-ui/public/goal-forest-render.js'], truncated: true }; };
335
+ await cascade.runCascadesForShip({ id: '7001' }, {
336
+ rules: [probe.rule], getTask: async () => shippedCopy({ module_key: null }),
337
+ findTaskBySourceRef: async () => null, createTask: async () => ({ id: '1' }), changedFiles,
338
+ });
371
339
  assert.equal(asked, 1);
372
- assert.equal(created.filter((s) => s.discipline === 'artist').length, 1, 'the unlabelled UI ship gets its review');
373
- assert.deepEqual(report.errors, []);
374
- });
375
-
376
- await test('a changed-files list cut off at the read cap FILES the review — a padded PR cannot skip it', async () => {
377
- const run = async (changed) => {
378
- const created = [];
379
- await cascade.runCascadesForShip({ id: '7001' }, {
380
- getTask: async () => shippedCopy({ module_key: null, discipline: 'engineer' }),
381
- findTaskBySourceRef: async () => null,
382
- createTask: async (spec) => { created.push(spec); return { id: '9500' }; },
383
- changedFiles: async () => changed,
384
- });
385
- return created.filter((s) => s.discipline === 'artist').length;
386
- };
387
- const padding = Array.from({ length: 300 }, (_, i) => `docs/pad-${i}.md`);
388
- assert.equal(await run({ paths: padding, truncated: true }), 1);
389
- assert.equal(await run({ paths: padding, truncated: false }), 0, 'the same list, complete, is not UI work');
390
- });
340
+ assert.deepEqual(probe.seen[0].changedFiles, ['modules/hall-ui/public/goal-forest-render.js']);
341
+ assert.equal(probe.seen[0].changedFilesTruncated, true);
391
342
 
392
- await test('a filed review is one the deploy gate HOLDS on — the rule and the gate agree on its identity', async () => {
343
+ // The production table, on the same ship: no artist review, nothing the
344
+ // deploy gate would hold, no error.
393
345
  const gate = require_(path.join(HERE, '..', '..', '..', 'src', 'bongos', 'artist-gate.js'));
394
346
  const created = [];
395
- await cascade.runCascadesForShip({ id: '7001' }, {
396
- getTask: async () => shippedCopy({ module_key: null, discipline: 'engineer' }),
347
+ const report = await cascade.runCascadesForShip({ id: '7001' }, {
348
+ getTask: async () => shippedCopy({ module_key: null }),
397
349
  findTaskBySourceRef: async () => null,
398
350
  createTask: async (spec) => { created.push(spec); return { id: '9600' }; },
399
- changedFiles: async () => ({ paths: ['modules/public-landing/public/projects.html'], truncated: false }),
351
+ changedFiles, artistGateMode: () => 'strict',
400
352
  });
401
- const review = created.find((s) => s.discipline === 'artist');
402
- assert.ok(review, 'the review was filed');
403
- assert.equal(gate.isArtistReview({ source: review.source, source_ref: review.sourceRef }), true);
353
+ assert.deepEqual(created, []);
354
+ assert.equal(created.some((s) => gate.isArtistReview({ source: s.source, source_ref: s.sourceRef })), false);
355
+ assert.deepEqual(report.errors, []);
404
356
  });
405
357
 
406
- await test('a changed-files read that fails is REPORTED, never thrown — the label match still stands', async () => {
407
- const run = async (task) => {
408
- const created = [];
409
- const report = await cascade.runCascadesForShip({ id: '7001' }, {
410
- getTask: async () => task,
411
- findTaskBySourceRef: async () => null,
412
- createTask: async (spec) => { created.push(spec); return { id: '9400' }; },
413
- changedFiles: async () => { throw new Error('github down'); },
414
- });
415
- return { created, report };
416
- };
417
- const unlabelled = await run(shippedCopy({ module_key: null, discipline: 'engineer' }));
418
- assert.equal(unlabelled.created.length, 0);
419
- assert.match(unlabelled.report.errors.join('\n'), /changedFiles\(\) threw \(github down\)/);
420
- const labelled = await run(shippedCopy({ module_key: 'hall-ui' }));
421
- assert.equal(labelled.created.filter((s) => s.discipline === 'artist').length, 1, 'an outage never costs a labelled review');
358
+ await test('a changed-files read that fails is REPORTED, never thrown', async () => {
359
+ const created = [];
360
+ const report = await cascade.runCascadesForShip({ id: '7001' }, {
361
+ getTask: async () => shippedCopy(),
362
+ findTaskBySourceRef: async () => null,
363
+ createTask: async (spec) => { created.push(spec); return { id: '9400' }; },
364
+ changedFiles: async () => { throw new Error('github down'); },
365
+ });
366
+ assert.equal(created.length, 0);
367
+ assert.match(report.errors.join('\n'), /changedFiles\(\) threw \(github down\)/);
422
368
  });
423
369
 
424
370
  const shipFiles = require_(path.join(HERE, '..', 'ship-files.js'));
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1066",
3
+ "version": "1.19.1067",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1066",
9
+ "version": "1.19.1067",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1066",
3
+ "version": "1.19.1067",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -7697,5 +7697,11 @@
7697
7697
  "id": "1004324",
7698
7698
  "text": "When an artist's page rewrite is approved and goes live, the artist is now paid 30 credits for it, the same value an artist review used to carry. The payment lands at the moment the page ships, never at approval, and a page th"
7699
7699
  }
7700
+ ],
7701
+ "1.19.1067": [
7702
+ {
7703
+ "id": "1004325",
7704
+ "text": "A ship that changes a page's words no longer files an \"Artist review\" task. Those reviews came one per ship, and most were thrown away unread. Instead, the page itself shows \"changed since last tweak\" in Tweak Mode, worked out"
7705
+ }
7700
7706
  ]
7701
7707
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1066'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1067'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -216,36 +216,28 @@ test('docs/voice.md exists — the lens has a standard to judge against', () =>
216
216
  }
217
217
  });
218
218
 
219
- // ---- 7. THE DONE-WHEN CLAUSE THAT LIVES IN ANOTHER FILE ---------------------
219
+ // ---- 7. THE ARTIST REVIEW THIS LENS WAS PAIRED WITH IS GONE -----------------
220
+ //
221
+ // Task 1003977 turned STEP 1 of the per-ship artist review into "coach the
222
+ // reader", because this lens was listening. Tweak Mode removed that review
223
+ // (task 1004325 / BV2.TW14, ADR 0341 D9): a ship now files no artist review at
224
+ // all. The lens stays, scoped by the same predicate, which cascade.js still
225
+ // exports for exactly this consumer.
220
226
 
221
- test('the artist review now coaches the reader, because there is a lens listening', () => {
222
- // The task's own DONE WHEN: "Then STEP 1 of the artist review reverts to true
223
- // coaching — see the comment in modules/lifecycle/cascade.js, which says so
224
- // and asks not to be reverted early." The interim wording was explicitly a
225
- // signal for which world we are in, so shipping the lens without turning it
226
- // leaves the instruction to artists describing a panel that no longer exists.
227
- const { artistReviewBody } = require('../modules/lifecycle/cascade.js');
228
- const body = artistReviewBody({ id: '1234' });
229
- assert.match(body, /COACH THE READER/, 'STEP 1 asks for coaching again');
230
- assert.match(body, /docs\/voice\.md/, 'and names the standard to coach against');
231
- assert.ok(!/none of them reads a sentence/.test(body),
232
- 'the interim claim that no lens reads a sentence is gone — it is no longer true');
233
- // Still a request, never a gate (ADR 0241) — unchanged by this task.
234
- assert.match(body, /REQUEST, not a gate/);
227
+ test('the per-ship artist review is gone, and the lens keeps its scope predicate', () => {
228
+ const cascade = require('../modules/lifecycle/cascade.js');
229
+ assert.equal(cascade.artistReviewBody, undefined, 'no review body is left to coach from');
230
+ assert.equal(cascade.CASCADES.some((r) => r.key === 'artist-review-on-ship'), false);
231
+ assert.equal(cascade.hasCopyOrVisualWork, hasCopyOrVisualWork,
232
+ 'the lens is scoped by cascade.js\'s own export, not a copy of it');
235
233
  });
236
234
 
237
- test('the cascade and the copy lens cover the SAME set by construction', () => {
238
- // Both key off hasCopyOrVisualWork, so an artist review exists for exactly
239
- // the ships a reader ran on. If these ever diverge, an artist is asked to
240
- // coach a lens that never looked.
241
- const cascadeSrc = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'), 'utf8');
235
+ test('the copy lens is scoped by the files the work changed', () => {
242
236
  const shipSrc = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
243
- assert.match(cascadeSrc, /hasCopyOrVisualWork/);
244
237
  assert.match(shipSrc, /hasCopyOrVisualWork/);
245
- // Task 1004201: BOTH consumers hand the predicate the files the work changed,
246
- // so an unlabelled UI ship gets the lens at grade time AND the review on ship.
238
+ // Task 1004201: the grader hands the predicate the files the work changed, so
239
+ // an unlabelled UI ship gets the lens at grade time.
247
240
  assert.match(shipSrc, /hasCopyOrVisualWork\(task, changedFiles\)/, 'the grader passes its own diff');
248
- assert.match(cascadeSrc, /hasCopyOrVisualWork\(t, \(ctx \|\| \{\}\)\.changedFiles\)/, 'the cascade passes the merged PR');
249
241
  const unlabelled = { module_key: null, discipline: 'engineer' };
250
242
  assert.equal(hasCopyOrVisualWork(unlabelled, ['modules/hall-ui/public/goal-forest-lib.js']), true);
251
243
  assert.equal(hasCopyOrVisualWork(unlabelled, ['modules/hall-ui/module.json', 'scripts/gds/claim.js']), false);
@@ -62,8 +62,11 @@ await test('the prefix does NOT match the other cascade rule', () => {
62
62
  // separating a removal question from an artist review. If it ever widened, a
63
63
  // Confirm button would appear on a copy review and answering it would record a
64
64
  // removal verdict about work nobody proposed removing.
65
- const artist = cascade.CASCADES.find((r) => r.key === 'artist-review-on-ship');
66
- const artistRef = cascade.cascadeSourceRef(artist.key, SHIP);
65
+ //
66
+ // The artist rule itself was removed by Tweak Mode (TW14, ADR 0341 D9), but the
67
+ // reviews it filed are still rows on the board under its key, so the key is
68
+ // spelled here rather than read from CASCADES.
69
+ const artistRef = cascade.cascadeSourceRef('artist-review-on-ship', SHIP);
67
70
  assert.ok(!artistRef.startsWith(proposals.PROPOSAL_SOURCE_REF_PREFIX));
68
71
  assert.equal(proposals.shipTaskIdFromSourceRef(artistRef), null);
69
72
  });
@@ -0,0 +1,170 @@
1
+ // tests/tweak_replaces_artist_review.mjs — Tweak Mode replaces the per-ship
2
+ // artist review, the WHOLE path (task 1004325 / BV2.TW14, ADR 0341 D8 and D9).
3
+ //
4
+ // The done-when: "a ship changing a page's text files no artist review and the
5
+ // page reads changed-since". Two halves, and they meet at one ship:
6
+ //
7
+ // 1. THE SHIP FILES NOTHING. The real cascade runner, with the real
8
+ // production table, the ship's real changed files and the artist gate at
9
+ // 'strict' (the level that used to file AND hold), creates no task.
10
+ // 2. THE PAGE READS CHANGED-SINCE ANYWAY, with no write. The real copy-desk
11
+ // route over the real lifecycle port's SQL (a fake pool), reading the page
12
+ // artifacts from disk: before the ship the page matches its last round;
13
+ // after it, the page's reading has moved, drift names the changed line, and
14
+ // the ship itself is listed as what changed it, found by its touches.
15
+ //
16
+ // "No write" is checked twice: the cascade's createTask is never called, and
17
+ // the fake pool refuses any SQL that is not one of the two reads, so an INSERT
18
+ // or UPDATE anywhere on the read path reds here.
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+
30
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
31
+ const cascade = require('../modules/lifecycle/cascade.js');
32
+ const gate = require('../src/bongos/artist-gate.js');
33
+ const api = require('../src/module-api.js');
34
+
35
+ // THE SHIP. An engineer rewords the studio greeting ("Welcome home" becomes
36
+ // "Welcome back") after the page's first round shipped. Its touches meet the
37
+ // studio page's files, and it is copy/visual work by every measure the old rule
38
+ // used: a hall-ui module ship that changed a page file.
39
+ const SHIP = {
40
+ id: '555', title: 'Reword the studio greeting', status: 'shipped',
41
+ discipline: 'engineer', kind: 'feature', source: 'manual', module_key: 'hall-ui',
42
+ version_id: 'BONGOS-V2', goal_id: '1000095',
43
+ touches: ['modules/hall-ui/public/studio.html'],
44
+ shipped_at: new Date('2026-09-22T09:00:00Z'),
45
+ };
46
+
47
+ // Every SQL the page read sends, so the test can say none of it wrote.
48
+ const seen = [];
49
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
50
+ api.pool = {
51
+ async query(sql, params = []) {
52
+ const q = flat(sql);
53
+ seen.push({ q, params });
54
+ if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
55
+ if (/t\.touches && \$1::text\[\]/.test(q)) {
56
+ // The real query's predicate, applied to the one ship: shipped after the
57
+ // page's last round, touching one of the page's files.
58
+ const files = new Set(params[0]);
59
+ const hit = SHIP.shipped_at > new Date(params[1]) && SHIP.touches.some((t) => files.has(t));
60
+ return { rows: hit ? [{ id: SHIP.id, title: SHIP.title, shipped_at: SHIP.shipped_at }] : [] };
61
+ }
62
+ throw new Error(`unexpected SQL on the page read: ${q.slice(0, 120)}`);
63
+ },
64
+ };
65
+ api.requireBuilder = (req, res, next) => { req.builder = { id: F.ALICE.id, rank: 'thetes' }; next(); };
66
+
67
+ const seams = require('../src/module-seams.js');
68
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
69
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
70
+
71
+ const pageData = require('../modules/copy-desk/page-data.js');
72
+ const express = require('express');
73
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
74
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
75
+
76
+ const app = express();
77
+ app.use(attachFail);
78
+ app.use(buildRouter());
79
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
80
+ const base = `http://127.0.0.1:${server.address().port}`;
81
+ async function get(p) {
82
+ const r = await fetch(base + p);
83
+ return { status: r.status, body: await r.json() };
84
+ }
85
+
86
+ // The studio's reading BEFORE the ship says exactly what its first round left
87
+ // ("Welcome home", "Resume"); AFTER it is the fixture's (the greeting moved).
88
+ // The shared shell line differs on both sides, and drift must ignore it.
89
+ const studioAfter = F.READINGS.pages.find((p) => p.id === 'builders:studio');
90
+ const studioBefore = {
91
+ ...studioAfter,
92
+ reading_hash: 'studio-before',
93
+ lines: studioAfter.lines.map((l) => (l.key === 'L0002' ? { ...l, text: 'Welcome home' } : l)),
94
+ };
95
+ const readingsWith = (studio) => ({ ...F.READINGS, pages: F.READINGS.pages.map((p) => (p.id === studio.id ? studio : p)) });
96
+
97
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tweak-replaces-review-'));
98
+ const root = (name, readings) => {
99
+ const dir = path.join(tmp, name);
100
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
101
+ fs.writeFileSync(path.join(dir, 'docs', 'page-inventory.json'), JSON.stringify(F.INVENTORY));
102
+ fs.writeFileSync(path.join(dir, 'docs', 'page-readings.json'), JSON.stringify(readings));
103
+ return dir;
104
+ };
105
+ const BEFORE = root('before', readingsWith(studioBefore));
106
+ const AFTER = root('after', readingsWith(studioAfter));
107
+
108
+ const { test, summary } = makeRunner();
109
+
110
+ try {
111
+ await test('before the ship, the studio matches its last round: tweaked, no drift', async () => {
112
+ pageData._setRoot(BEFORE);
113
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
114
+ assert.equal(status, 200);
115
+ assert.equal(body.status_label, 'Text tweaked 1x');
116
+ assert.equal(body.drift.changed, false);
117
+ });
118
+
119
+ await test('the ship that rewords the page files NO artist review, at the strictest gate', async () => {
120
+ const created = [];
121
+ const report = await cascade.runCascadesForShip({ id: SHIP.id }, {
122
+ getTask: async () => SHIP,
123
+ findTaskBySourceRef: async () => null,
124
+ createTask: async (spec) => { created.push(spec); return { id: '9999' }; },
125
+ artistGateMode: () => 'strict',
126
+ changedFiles: async () => ({ paths: SHIP.touches, truncated: false }),
127
+ removalCandidates: async () => ({ removals: [] }),
128
+ });
129
+ assert.deepEqual(report.errors, []);
130
+ assert.deepEqual(created, [], 'nothing is filed: no review task, no marker, nothing');
131
+ assert.equal(created.some((s) => gate.isArtistReview({ source: s.source, source_ref: s.sourceRef })), false);
132
+ // It is still copy/visual work: the grader's reader lens still runs on it.
133
+ assert.equal(cascade.hasCopyOrVisualWork(SHIP, SHIP.touches), true);
134
+ });
135
+
136
+ await test('after the ship, the page reads changed-since, and names the ship, with no write', async () => {
137
+ pageData._setRoot(AFTER);
138
+ seen.length = 0;
139
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
140
+ assert.equal(status, 200);
141
+ assert.equal(body.drift.known, true);
142
+ assert.equal(body.drift.changed, true, 'the page reads "changed since last tweak"');
143
+ assert.deepEqual(body.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
144
+ assert.deepEqual(body.drift.removed_lines, ['Welcome home']);
145
+ assert.deepEqual(body.drift.ships.map((s) => s.task_id), [SHIP.id], 'the ship is what changed it');
146
+ assert.equal(body.status_label, 'Text tweaked 1x', 'drift never lowers the count');
147
+ // Derived, never stored: only the two reads ran.
148
+ assert.ok(seen.length > 0);
149
+ for (const s of seen) assert.match(s.q, /^(WITH .*)?SELECT /, `a read, never a write: ${s.q.slice(0, 80)}`);
150
+ });
151
+
152
+ await test('the page list carries the same drift, which is what the recommendation ranks on', async () => {
153
+ pageData._setRoot(AFTER);
154
+ const list = await get('/copy-desk/pages');
155
+ const studio = list.body.pages.find((p) => p.id === 'builders:studio');
156
+ assert.equal(studio.drift.changed, true);
157
+ assert.equal(studio.drift.changed_count, 1);
158
+ });
159
+ } finally {
160
+ pageData._setRoot(null);
161
+ server.closeAllConnections();
162
+ await new Promise((resolve) => server.close(resolve));
163
+ fs.rmSync(tmp, { recursive: true, force: true });
164
+ }
165
+
166
+ // Let the closed sockets finish closing before summary() calls process.exit: on
167
+ // Windows an exit while a handle is mid-close trips a libuv assertion (exit 127)
168
+ // after every case has passed.
169
+ await new Promise((resolve) => setTimeout(resolve, 100));
170
+ summary();