@bongos/core 1.19.589 → 1.19.591

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.
@@ -24,6 +24,8 @@ process.env.NODE_ENV = 'test';
24
24
  const require = createRequire(import.meta.url);
25
25
  const { openTasksInGoal } = require('../modules/lifecycle/db.js');
26
26
  const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
27
+ const { planGoalArchiveDispositions } = require('../modules/lifecycle/routes/goal-route-authz.js');
28
+ const { archiveGoalWithDispositions } = require('../modules/lifecycle/db.js');
27
29
 
28
30
  const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
29
31
 
@@ -86,15 +88,24 @@ test('an empty goal reports nothing held — the single-call path stays open', a
86
88
  // ---- the route --------------------------------------------------------------
87
89
 
88
90
  test('archive refuses with a NAMED 409 when the goal holds open work', () => {
89
- assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id\);/,
91
+ // R14 (task 1003601) moved this refusal OUT of the route and into the pure
92
+ // planner, so it is asserted on behaviour now rather than on route source —
93
+ // a strictly better pin, and the reason these two tests changed shape.
94
+ assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id/,
90
95
  'the archive route must consult the work before discarding it');
91
- assert.match(ARCHIVE, /res\.fail\('goal_holds_open_tasks', \{[\s\S]*?status: 409/,
92
- 'a named code an agent can branch on, not a bare 409');
96
+ const r = planGoalArchiveDispositions({
97
+ goalId: 7, openTasks: [{ id: '1', title: 't', status: 'ready' }], dispositions: null, reason: 'x',
98
+ });
99
+ assert.equal(r.ok, false);
100
+ assert.equal(r.status, 409);
101
+ assert.equal(r.body.error, 'goal_holds_open_tasks', 'a named code an agent can branch on, not a bare 409');
93
102
  });
94
103
 
95
104
  test('the refusal RETURNS the tasks, so the caller can decide without a second fetch', () => {
96
- assert.match(ARCHIVE, /details: \{ total: held\.total, shown: held\.tasks\.length, tasks: held\.tasks \}/,
97
- 'an agent that has to go fetch the list will guess instead');
105
+ const held = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
106
+ const r = planGoalArchiveDispositions({ goalId: 7, openTasks: held, dispositions: null, reason: 'x' });
107
+ assert.deepEqual(r.body.details.tasks, held, 'an agent that has to go fetch the list will guess instead');
108
+ assert.equal(r.body.details.total, 2);
98
109
  });
99
110
 
100
111
  test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
@@ -111,9 +122,14 @@ test('the check runs AFTER identity/authorization and BEFORE the status write',
111
122
  test('archiving a goal that holds nothing is still ONE call', () => {
112
123
  // The guard must not turn the ordinary case into a two-step. There is exactly
113
124
  // one setGoalStatus in this handler and no second round trip in front of it.
114
- assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1);
125
+ assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1,
126
+ 'the empty-goal path still writes through the plain setter, not the transaction');
115
127
  assert.equal(/disposition_required|preview=/.test(ARCHIVE), false,
116
128
  'no extra confirm parameter on the empty-goal path');
129
+ // And the planner agrees: nothing held means an empty plan, never a refusal.
130
+ const r = planGoalArchiveDispositions({ goalId: 7, openTasks: [], dispositions: null, reason: '' });
131
+ assert.equal(r.ok, true);
132
+ assert.deepEqual(r.plan, []);
117
133
  });
118
134
 
119
135
  // ---- the two doors agree ----------------------------------------------------
@@ -142,3 +158,188 @@ test('every non-terminal status is one the archive gate would catch', () => {
142
158
  assert.deepEqual([...TERMINAL_STATUSES].sort(), ['abandoned', 'shipped'],
143
159
  'and the terminal set is exactly the two deliberate endings');
144
160
  });
161
+
162
+ // ---- R14: the disposition is APPLIED (task 1003601) --------------------------
163
+ //
164
+ // The second half of the two-step. R10 built the refusal; this is the answer to
165
+ // it. The property under test throughout is TOTALITY: a map that does not cover
166
+ // every open task is refused outright rather than applied as far as it goes,
167
+ // because a half-applied disposition leaves the goal un-archivable AND some of
168
+ // its work already abandoned, with no way for the caller to tell which happened.
169
+
170
+ const TASKS = (...st) => st.map((s, i) => ({ id: String(i + 1), title: `t${i + 1}`, status: s }));
171
+ const plan = (over = {}) => planGoalArchiveDispositions({
172
+ goalId: 7, openTasks: TASKS('ready', 'active'), reason: 'cut in the ten-areas review', ...over,
173
+ });
174
+
175
+ test('a partial map is refused — nothing is discarded by omission', () => {
176
+ const r = plan({ dispositions: { 1: { verb: 'abandon' } } }); // task 2 undecided
177
+ assert.equal(r.ok, false);
178
+ assert.equal(r.status, 400);
179
+ assert.equal(r.body.error, 'disposition_incomplete');
180
+ assert.equal(r.body.details.undecided, 1);
181
+ assert.deepEqual(r.body.details.tasks.map((t) => t.id), ['2'],
182
+ 'the refusal names WHICH task is undecided, not merely how many');
183
+ });
184
+
185
+ test('a complete map plans one step per task, in the order the tasks came in', () => {
186
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
187
+ assert.equal(r.ok, true);
188
+ assert.deepEqual(r.plan, [
189
+ { taskId: 1, verb: 'abandon', toGoalId: null },
190
+ { taskId: 2, verb: 'move', toGoalId: 42 },
191
+ ]);
192
+ });
193
+
194
+ test('an abandon with no reason is refused — that is the silent discard D3 forbids', () => {
195
+ for (const reason of ['', ' ', null, undefined]) {
196
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } }, reason });
197
+ assert.equal(r.ok, false, `reason ${JSON.stringify(reason)} must not pass`);
198
+ assert.equal(r.body.error, 'archive_reason_required');
199
+ }
200
+ });
201
+
202
+ test('only abandon and move are verbs; anything else is a named 400', () => {
203
+ for (const verb of ['delete', 'ABANDON', 'skip', '', null, 42]) {
204
+ const r = plan({ dispositions: { 1: { verb }, 2: { verb: 'abandon' } } });
205
+ assert.equal(r.ok, false, `verb ${JSON.stringify(verb)} must not pass`);
206
+ assert.equal(r.body.error, 'bad_disposition');
207
+ }
208
+ });
209
+
210
+ test('a move must name where it moves to', () => {
211
+ for (const goal_id of [undefined, null, 0, -1, 'forty-two', 1.5]) {
212
+ const r = plan({ dispositions: { 1: { verb: 'move', goal_id }, 2: { verb: 'abandon' } } });
213
+ assert.equal(r.ok, false, `goal_id ${JSON.stringify(goal_id)} must not pass`);
214
+ assert.equal(r.body.error, 'bad_disposition');
215
+ }
216
+ });
217
+
218
+ test('a task cannot be moved INTO the goal being archived', () => {
219
+ // The disposition that looks like a decision and is not: the task would be
220
+ // archived along with the goal a moment later — the silent discard renamed.
221
+ const r = plan({ dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
222
+ assert.equal(r.ok, false);
223
+ assert.equal(r.body.error, 'bad_disposition');
224
+ assert.match(r.body.message, /into the goal being archived/);
225
+ });
226
+
227
+ test('a disposition for a task that is not open is simply unused, never an error', () => {
228
+ // The caller may hold a stale list; extra keys are harmless because the plan is
229
+ // built from the OPEN TASKS, not from the map.
230
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' }, 999: { verb: 'move' } } });
231
+ assert.equal(r.ok, true);
232
+ assert.deepEqual(r.plan.map((p) => p.taskId), [1, 2]);
233
+ });
234
+
235
+ // ---- R14: the write is one transaction --------------------------------------
236
+
237
+ function txPool(handler) {
238
+ const c = makeSqlAwareClient(handler);
239
+ return { connect: c.connect, query: (sql, params) => c._client.query(sql, params), queries: c._client.queries };
240
+ }
241
+ const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
242
+
243
+ test('the whole disposition + archive runs inside ONE transaction', async () => {
244
+ const pool = txPool((sql) => {
245
+ const q = String(sql).replace(/\s+/g, ' ');
246
+ if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE/.test(q.trim())) return { rows: [{ id: 7, status: 'open' }] };
247
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't1', status: 'ready' }] };
248
+ if (/FROM claims WHERE task_id/.test(q)) return { rows: [] };
249
+ if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 0 }] };
250
+ if (/UPDATE goals SET status = 'archived'/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
251
+ return { rows: [] };
252
+ });
253
+ const out = await archiveGoalWithDispositions(
254
+ { goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'cut' },
255
+ { pool }
256
+ );
257
+ assert.equal(out.goal.status, 'archived');
258
+ const sqls = pool.queries.map(flat);
259
+ assert.equal(sqls.filter((q) => q === 'BEGIN').length, 1, 'exactly one BEGIN');
260
+ assert.equal(sqls.filter((q) => q === 'COMMIT').length, 1, 'exactly one COMMIT');
261
+ assert.ok(sqls.indexOf('BEGIN') < sqls.findIndex((q) => /UPDATE tasks SET status = 'abandoned'/.test(q)),
262
+ 'the task writes are inside the transaction');
263
+ assert.ok(sqls.findIndex((q) => /UPDATE goals SET status = 'archived'/.test(q)) < sqls.indexOf('COMMIT'),
264
+ 'so is the archive');
265
+ });
266
+
267
+ test('the goal row is locked BEFORE the plan is applied', async () => {
268
+ // Without FOR UPDATE on the goal, a concurrent POST /goals/:id/tasks can slip a
269
+ // new task in between the plan validating and the archive landing.
270
+ const pool = txPool((sql) => {
271
+ const q = String(sql).replace(/\s+/g, ' ');
272
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
273
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
274
+ if (/FROM claims/.test(q)) return { rows: [] };
275
+ if (/count\(\*\)::int AS n/.test(q)) return { rows: [{ n: 0 }] };
276
+ if (/UPDATE goals/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
277
+ return { rows: [] };
278
+ });
279
+ await archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool });
280
+ const sqls = pool.queries.map(flat);
281
+ const lock = sqls.findIndex((q) => /FROM goals WHERE id = \$1 FOR UPDATE/.test(q));
282
+ const firstWrite = sqls.findIndex((q) => /^UPDATE tasks/.test(q));
283
+ assert.ok(lock !== -1 && firstWrite !== -1 && lock < firstWrite, 'lock the goal, then apply');
284
+ });
285
+
286
+ test('an actively-claimed task stops the archive and rolls it back', async () => {
287
+ const pool = txPool((sql) => {
288
+ const q = String(sql).replace(/\s+/g, ' ');
289
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
290
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'busy', status: 'active' }] };
291
+ if (/FROM claims WHERE task_id/.test(q)) return { rows: [{ id: 99 }] }; // someone is on it
292
+ return { rows: [] };
293
+ });
294
+ await assert.rejects(
295
+ archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool }),
296
+ (e) => e.code === 'TASK_HAS_ACTIVE_CLAIM'
297
+ );
298
+ const sqls = pool.queries.map(flat);
299
+ assert.ok(sqls.includes('ROLLBACK'), 'nothing is left applied');
300
+ assert.equal(sqls.some((q) => /UPDATE goals SET status = 'archived'/.test(q)), false, 'and the goal is not archived');
301
+ });
302
+
303
+ test('the invariant is re-checked against committed state, not against the plan', async () => {
304
+ // The one check a bug in the planner cannot fool: if anything is still
305
+ // non-terminal after the writes, the archive rolls back rather than recreating
306
+ // exactly the condition goal 1000086 exists to make impossible.
307
+ const pool = txPool((sql) => {
308
+ const q = String(sql).replace(/\s+/g, ' ');
309
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
310
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
311
+ if (/FROM claims/.test(q)) return { rows: [] };
312
+ if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 3 }] }; // three snuck in
313
+ return { rows: [] };
314
+ });
315
+ await assert.rejects(
316
+ archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool }),
317
+ (e) => e.code === 'DISPOSITION_INCOMPLETE_AFTER_APPLY' && e.remaining === 3
318
+ );
319
+ assert.ok(pool.queries.map(flat).includes('ROLLBACK'));
320
+ });
321
+
322
+ test('the re-check asks the SAME question the refusal asked', () => {
323
+ // Same terminal set, same smoke exclusion — or the transaction rolls back work
324
+ // the gate in front of it had already accepted.
325
+ const dbGoals = src('modules/lifecycle/db-goals.js');
326
+ const body = dbGoals.slice(dbGoals.indexOf('async function archiveGoalWithDispositions'));
327
+ assert.match(body, /\$\{nonTerminalSql\(\)\}/);
328
+ assert.match(body, /SMOKE_NOT_LIKE\.replace/);
329
+ });
330
+
331
+ test('an abandon stamps the archive reason, the way POST /tasks/:id/abandon does', () => {
332
+ const dbGoals = src('modules/lifecycle/db-goals.js');
333
+ const body = dbGoals.slice(dbGoals.indexOf('async function archiveGoalWithDispositions'));
334
+ assert.match(body, /`Abandoned: \$\{reason\}`/,
335
+ 'the same stamp abandonTask writes, so the ledger reads identically whichever door was used');
336
+ });
337
+
338
+ test('a move into a closed goal is refused, via the ONE definition of "not open"', () => {
339
+ // The route resolves move targets and applies goalNotOpenRefusal (BV1.R05)
340
+ // rather than re-typing the status list — two spellings of "open" is how the
341
+ // three task-create vectors drifted apart in the first place.
342
+ assert.match(ARCHIVE, /goalNotOpenRefusal\(dest\)/);
343
+ assert.equal(/dest\.status === 'open'|dest\.status !== 'open'/.test(ARCHIVE), false,
344
+ 'the route must not re-implement the open test');
345
+ });
@@ -0,0 +1,135 @@
1
+ // tests/goal_archive_route_e2e.mjs — the archive two-step over real HTTP
2
+ // (BV1.R14, task 1003601, goal 1000086, ADR 0250 D3).
3
+ //
4
+ // WHY A SECOND FILE. goal_archive_disposition.mjs pins the two halves in
5
+ // isolation — the pure planner's truth table, and the writer's transaction shape
6
+ // against a fake pool. Neither exercises the WIRING: that the route reads the
7
+ // body it declares, calls the planner with the right arguments, renders its
8
+ // verdict verbatim, resolves move targets BEFORE writing, and hands the writer a
9
+ // plan. Every one of those is a place a correct planner and a correct writer can
10
+ // still add up to a broken endpoint, and each was a real risk on this change
11
+ // (the route previously read no body at all).
12
+ //
13
+ // So this drives the REAL router over a real socket, with the db layer stubbed at
14
+ // the module boundary the way task_visual_route_gate.mjs does — the router under
15
+ // test is the shipped one, not a re-assembly of it.
16
+ //
17
+ // THE CASE WORTH THE FILE ON ITS OWN is #6: a typo'd `disposition` (singular).
18
+ // Without the ADR 0118 schema the handler reads `req.body.dispositions` as
19
+ // undefined and takes the REFUSAL branch — so a caller who wrote a complete map
20
+ // under a misspelled key is told the goal holds open work, which is true,
21
+ // unhelpful, and indistinguishable from having sent nothing. That is the failure
22
+ // shape that teaches an agent to retry with a different map instead of fixing the
23
+ // key.
24
+ //
25
+ // Run: node tests/goal_archive_route_e2e.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ const require = createRequire(import.meta.url);
30
+ process.env.NODE_ENV = 'test';
31
+
32
+ const express = require('express');
33
+ const api = require('../src/module-api.js');
34
+ const db = require('../modules/lifecycle/db.js');
35
+
36
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
37
+ api.requirePermission = () => (req, _res, next) => next();
38
+
39
+ let GOALS = {
40
+ 7: { id: 7, status: 'open', title: 'cut me', version_id: 'BONGOS-V1' },
41
+ 42: { id: 42, status: 'open', title: 'survivor', version_id: 'BONGOS-V1' },
42
+ 99: { id: 99, status: 'archived', title: 'closed', version_id: 'BONGOS-V1' },
43
+ };
44
+ let OPEN = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
45
+ let applied = null;
46
+ db.getGoal = async (id) => GOALS[Number(id)] ?? null;
47
+ db.isGoalMember = async () => true;
48
+ db.openTasksInGoal = async () => ({ total: OPEN.length, tasks: OPEN });
49
+ db.setGoalStatus = async ({ goalId }) => ({ ...GOALS[goalId], status: 'archived' });
50
+ db.archiveGoalWithDispositions = async ({ goalId, plan, reason }) => {
51
+ applied = { goalId, plan, reason };
52
+ return { goal: { ...GOALS[goalId], status: 'archived' }, applied: plan.map(p => ({ task_id: String(p.taskId), verb: p.verb, result: p.verb === 'abandon' ? 'abandoned' : 'moved' })) };
53
+ };
54
+
55
+ const app = express();
56
+ app.use(express.json());
57
+ app.use((req, res, next) => {
58
+ res.fail = (code, statusOrOpts, details) => {
59
+ const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
60
+ return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
61
+ };
62
+ next();
63
+ });
64
+ app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
65
+ const server = app.listen(0);
66
+ await new Promise(r => server.once('listening', r));
67
+ const base = `http://127.0.0.1:${server.address().port}/api/gds`;
68
+ const post = async (p, body) => {
69
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
70
+ return { status: r.status, body: await r.json().catch(() => null) };
71
+ };
72
+
73
+ let n = 0; const ok = (m) => { n++; console.log(' ✓', m); };
74
+
75
+ // 1. bare archive of a goal holding work → the R10 refusal, WITH the tasks
76
+ let r = await post('/goals/7/archive');
77
+ assert.equal(r.status, 409); assert.equal(r.body.error, 'goal_holds_open_tasks');
78
+ assert.equal(r.body.details.tasks.length, 2);
79
+ ok('bare archive refuses 409 goal_holds_open_tasks and returns both tasks');
80
+
81
+ // 2. a partial map → refused, names the undecided task
82
+ r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'abandon' } } });
83
+ assert.equal(r.status, 400); assert.equal(r.body.error, 'disposition_incomplete');
84
+ assert.deepEqual(r.body.details.tasks.map(t => t.id), ['2']);
85
+ ok('partial map refuses 400 disposition_incomplete, naming task 2');
86
+
87
+ // 3. complete map with no reason → refused
88
+ r = await post('/goals/7/archive', { dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } } });
89
+ assert.equal(r.status, 400); assert.equal(r.body.error, 'archive_reason_required');
90
+ ok('an abandon with no reason refuses 400 archive_reason_required');
91
+
92
+ // 4. move into a CLOSED goal → refused before any write
93
+ applied = null;
94
+ r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 99 }, 2: { verb: 'abandon' } } });
95
+ assert.equal(r.status, 409); assert.equal(r.body.error, 'bad_disposition');
96
+ assert.equal(applied, null, 'nothing was written');
97
+ ok('move into an archived goal refuses 409 bad_disposition, writing nothing');
98
+
99
+ // 5. move into the goal being archived → refused
100
+ r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
101
+ assert.equal(r.status, 400); assert.equal(r.body.error, 'bad_disposition');
102
+ ok('move into the goal being archived refuses 400 bad_disposition');
103
+
104
+ // 6. unknown body field → rejected by the ADR 0118 schema
105
+ r = await post('/goals/7/archive', { reason: 'cut', disposition: { 1: { verb: 'abandon' } } });
106
+ assert.equal(r.status, 400);
107
+ assert.equal(r.body.error?.code ?? r.body.error, 'validation_failed');
108
+ ok('a typo\'d `disposition` (singular) is rejected as validation_failed, not read as "none given"');
109
+
110
+ // 7. THE HAPPY PATH — complete map, one abandon + one move
111
+ applied = null;
112
+ r = await post('/goals/7/archive', { reason: 'cut in the ten-areas review', dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
113
+ assert.equal(r.status, 200, JSON.stringify(r.body));
114
+ assert.equal(r.body.goal.status, 'archived');
115
+ assert.deepEqual(r.body.applied, [
116
+ { task_id: '1', verb: 'abandon', result: 'abandoned' },
117
+ { task_id: '2', verb: 'move', result: 'moved' },
118
+ ]);
119
+ assert.deepEqual(applied.plan, [
120
+ { taskId: 1, verb: 'abandon', toGoalId: null },
121
+ { taskId: 2, verb: 'move', toGoalId: 42 },
122
+ ]);
123
+ assert.equal(applied.reason, 'cut in the ten-areas review');
124
+ ok('a complete map archives the goal and reports what happened to each task');
125
+
126
+ // 8. a goal holding nothing is still ONE call, no reason needed
127
+ OPEN = [];
128
+ r = await post('/goals/7/archive');
129
+ assert.equal(r.status, 200); assert.equal(r.body.goal.status, 'archived');
130
+ assert.deepEqual(r.body.applied, []);
131
+ ok('a goal holding nothing archives in one call with no reason');
132
+
133
+ assert.equal(n, 8, 'every check must run — an early return would pass silently');
134
+ console.log(`\n${n}/8 end-to-end checks passed against the real router.`);
135
+ server.close();
@@ -0,0 +1,236 @@
1
+ // tests/rename_history_restraint.mjs — the RESTRAINT half of the GDS→Bongos
2
+ // rename (task 1003697, criterion C5 of goal 1000073: "history is intact and
3
+ // the alias still answers").
4
+ //
5
+ // WHY THE NEGATIVE CASES ARE THE LOAD-BEARING ONES. Its sibling
6
+ // tests/fitness_gds_ratchet.mjs makes the same argument and it applies twice as
7
+ // hard here: a restraint check that only asserts today's tree is clean would
8
+ // pass identically if every checker returned "ok" unconditionally. So each of
9
+ // the four properties gets a synthetic FORBIDDEN change and an assertion that
10
+ // the checker FAILS it. The positive case is the cheap half.
11
+ //
12
+ // The checkers are pure and take their inputs explicitly, so every case names
13
+ // its own paths and counts. Nothing here writes to the repo — the unit lane
14
+ // runs test files in parallel, and an earlier version of the ratchet test
15
+ // proved what happens when a suite edits a tree another suite is scanning.
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const check = require(path.join(ROOT, 'scripts', 'gds', 'rename-history-check.js'));
25
+ const scan = require(path.join(ROOT, 'scripts', 'gds', 'gds-literal-scan.js'));
26
+
27
+ const LITERAL = ['G', 'D', 'S'].join(''); // built, not written, so the fixtures below carry no vocabulary of their own
28
+
29
+ // ---------- 1. frozen filenames: a renamed applied migration RE-RUNS ----------
30
+
31
+ test('a migration that kept its name passes', () => {
32
+ const pinned = ['migrations/019_rename_pms_v3_to_gds_v3.sql'];
33
+ const r = check.checkFrozenFilenames({ present: pinned, pinned });
34
+ assert.equal(r.ok, true);
35
+ assert.deepEqual(r.missing, []);
36
+ });
37
+
38
+ test('a RENAMED migration fails and is named — schema_migrations keys on the stem', () => {
39
+ const pinned = [
40
+ 'migrations/019_rename_pms_v3_to_gds_v3.sql',
41
+ 'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
42
+ ];
43
+ // exactly what a scripts/gds/ → scripts/bongos/ codemod would do to these two
44
+ const present = [
45
+ 'migrations/019_rename_pms_v3_to_bongos_v3.sql',
46
+ 'modules/economy/migrations/economy_001_fix_bongos_shipper_description.sql',
47
+ ];
48
+ const r = check.checkFrozenFilenames({ present, pinned });
49
+ assert.equal(r.ok, false, 'a renamed migration MUST fail — it reads as unapplied and re-runs');
50
+ assert.deepEqual(r.missing, pinned);
51
+ });
52
+
53
+ test('a DELETED migration fails the same way as a renamed one', () => {
54
+ const r = check.checkFrozenFilenames({ present: [], pinned: ['migrations/001_init.sql'] });
55
+ assert.equal(r.ok, false);
56
+ assert.deepEqual(r.missing, ['migrations/001_init.sql']);
57
+ });
58
+
59
+ test('a NEW migration alongside the pinned ones is fine — the list is a floor', () => {
60
+ const r = check.checkFrozenFilenames({
61
+ present: ['migrations/001_init.sql', 'migrations/999_new.sql'],
62
+ pinned: ['migrations/001_init.sql'],
63
+ });
64
+ assert.equal(r.ok, true);
65
+ });
66
+
67
+ // ---------- 2. frozen literal floors: a rewritten ADR erases the evidence ----------
68
+
69
+ test('an unchanged record passes, and MORE occurrences pass — the floor only stops erasure', () => {
70
+ const floors = { 'docs/adr/0064-rename.md': 12 };
71
+ assert.equal(check.checkFrozenLiteralFloors({ counts: { 'docs/adr/0064-rename.md': 12 }, floors }).ok, true);
72
+ assert.equal(check.checkFrozenLiteralFloors({ counts: { 'docs/adr/0064-rename.md': 30 }, floors }).ok, true);
73
+ });
74
+
75
+ test('an ADR REWRITTEN to today’s vocabulary fails, with the before/after', () => {
76
+ const r = check.checkFrozenLiteralFloors({
77
+ counts: { 'docs/adr/0064-rename.md': 3 },
78
+ floors: { 'docs/adr/0064-rename.md': 12 },
79
+ });
80
+ assert.equal(r.ok, false, 'rewriting the record MUST fail — it destroys the evidence the words changed');
81
+ assert.deepEqual(r.dropped, [{ path: 'docs/adr/0064-rename.md', was: 12, now: 3 }]);
82
+ });
83
+
84
+ test('a DELETED record counts as zero — deleting fails as loudly as rewriting', () => {
85
+ const r = check.checkFrozenLiteralFloors({
86
+ counts: {},
87
+ floors: { 'docs/session-logs/2026-09-06-something.md': 4 },
88
+ });
89
+ assert.equal(r.ok, false);
90
+ assert.deepEqual(r.dropped, [{ path: 'docs/session-logs/2026-09-06-something.md', was: 4, now: 0 }]);
91
+ });
92
+
93
+ test('every dropped file is reported, not just the first', () => {
94
+ const r = check.checkFrozenLiteralFloors({
95
+ counts: { 'docs/adr/a.md': 0, 'docs/adr/b.md': 1 },
96
+ floors: { 'docs/adr/a.md': 5, 'docs/adr/b.md': 5, 'docs/adr/c.md': 5 },
97
+ });
98
+ assert.equal(r.dropped.length, 3, 'a codemod breaks many files at once; one-at-a-time reporting is useless');
99
+ });
100
+
101
+ // ---------- 3. version ids are row keys, not vocabulary ----------
102
+
103
+ test('version id floors pass when the keys survive, fail on a blanket find-replace', () => {
104
+ const floors = { 'GDS-V3': 183, 'GDS-V4': 188 };
105
+ assert.equal(check.checkVersionIdFloors({ counts: { 'GDS-V3': 183, 'GDS-V4': 200 }, floors }).ok, true);
106
+
107
+ const r = check.checkVersionIdFloors({ counts: { 'GDS-V3': 0, 'GDS-V4': 188 }, floors });
108
+ assert.equal(r.ok, false, 'rewriting a version id MUST fail — it is a row key and stops resolving');
109
+ assert.deepEqual(r.dropped, [{ id: 'GDS-V3', was: 183, now: 0 }]);
110
+ });
111
+
112
+ test('the ratchet deliberately ignores version ids, so THIS check is the only thing watching them', () => {
113
+ // gds-literal-scan strips version ids before counting — that is correct for
114
+ // the ratchet and is exactly why the floors above have to exist.
115
+ assert.equal(scan.countInText(`${LITERAL}-V3 and ${LITERAL}-V4`), 0);
116
+ assert.deepEqual(check.PINNED_VERSION_IDS, ['GDS-V3', 'GDS-V4']);
117
+ });
118
+
119
+ // ---------- 4. the permanent alias mount ----------
120
+
121
+ test('the alias mount passes while present and FAILS when dropped', () => {
122
+ const required = ['/api/gds'];
123
+ assert.equal(check.checkAliasMounts({ mounts: ['/api/bongos/v1', '/api/bongos', '/api/gds'], required }).ok, true);
124
+
125
+ const r = check.checkAliasMounts({ mounts: ['/api/bongos/v1', '/api/bongos'], required });
126
+ assert.equal(r.ok, false, 'shipped Dev Box binaries call the alias and cannot be force-updated');
127
+ assert.deepEqual(r.missing, ['/api/gds']);
128
+ });
129
+
130
+ test('the alias is asserted against the real exported mount list, not a grep', () => {
131
+ // A comment mentioning the alias must not be able to satisfy the check.
132
+ const { ALL_API_PREFIXES } = require(path.join(ROOT, 'src', 'bongos', 'api-prefix.js'));
133
+ assert.ok(ALL_API_PREFIXES.includes('/api/gds'), 'src/bongos/api-prefix.js still mounts the permanent alias');
134
+ });
135
+
136
+ // ---------- runAll: one broken codemod reports every property it broke ----------
137
+
138
+ test('runAll reports ALL four failures together', () => {
139
+ const observed = {
140
+ migrationFiles: [],
141
+ frozenLiteralCounts: {},
142
+ versionIdCounts: { 'GDS-V3': 0, 'GDS-V4': 0 },
143
+ aliasMounts: ['/api/bongos'],
144
+ };
145
+ const baseline = {
146
+ migrationFiles: ['migrations/001_init.sql'],
147
+ frozenLiteralFloors: { 'docs/adr/0064-rename.md': 12 },
148
+ versionIdFloors: { 'GDS-V3': 183, 'GDS-V4': 188 },
149
+ };
150
+ const report = check.runAll({ observed, baseline });
151
+ assert.equal(report.ok, false);
152
+ for (const key of ['frozenFilenames', 'frozenLiteralFloors', 'versionIdFloors', 'aliasMounts']) {
153
+ assert.equal(report.results[key].ok, false, `${key} should have failed`);
154
+ }
155
+ const text = check.describe(report).join('\n');
156
+ for (const fragment of ['RE-RUNS', 'ERASED', 'REWRITTEN', 'REMOVED']) {
157
+ assert.ok(text.includes(fragment), `the failure report should say what broke: ${fragment}`);
158
+ }
159
+ });
160
+
161
+ // ---------- observe(): the aggregation, on injected inputs ----------
162
+
163
+ test('observe classifies frozen vs go-forward files and counts version ids everywhere', () => {
164
+ const files = [
165
+ 'docs/adr/0064-rename.md',
166
+ 'migrations/019_rename_pms_v3_to_gds_v3.sql',
167
+ 'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
168
+ 'src/bongos/routes.js',
169
+ ];
170
+ const texts = {
171
+ 'docs/adr/0064-rename.md': `the ${LITERAL} acronym, twice: ${LITERAL}. and version GDS-V3`,
172
+ 'migrations/019_rename_pms_v3_to_gds_v3.sql': '-- GDS-V3',
173
+ 'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql': '-- nothing',
174
+ 'src/bongos/routes.js': `a go-forward mention of ${LITERAL} that this check does NOT pin`,
175
+ };
176
+ const observed = check.observe({
177
+ files,
178
+ readFile: (p) => texts[p],
179
+ aliasMounts: ['/api/gds'],
180
+ });
181
+
182
+ // module-owned migrations are found by the same rule as the core's
183
+ assert.deepEqual(observed.migrationFiles, [
184
+ 'migrations/019_rename_pms_v3_to_gds_v3.sql',
185
+ 'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
186
+ ]);
187
+ // the frozen record is floored; a go-forward file is NOT (that is the ratchet's job)
188
+ assert.deepEqual(Object.keys(observed.frozenLiteralCounts), ['docs/adr/0064-rename.md']);
189
+ assert.equal(observed.frozenLiteralCounts['docs/adr/0064-rename.md'], 2, 'the version id on the line is not counted as vocabulary');
190
+ // version ids are counted across every file, frozen or not
191
+ assert.equal(observed.versionIdCounts['GDS-V3'], 2);
192
+ assert.equal(observed.versionIdCounts['GDS-V4'], 0);
193
+ });
194
+
195
+ test('the shared version-id regexes carry no state between files', () => {
196
+ // observe() compiles the version-id regexes ONCE and reuses them across every
197
+ // tracked file. That is only safe because String.match with a global regex
198
+ // ignores lastIndex — so count the same id across several files and assert
199
+ // none of them are silently skipped.
200
+ const files = ['a.md', 'b.md', 'c.md', 'd.md'];
201
+ const observed = check.observe({
202
+ files,
203
+ readFile: () => 'GDS-V3 GDS-V3 GDS-V4',
204
+ aliasMounts: ['/api/gds'],
205
+ });
206
+ assert.equal(observed.versionIdCounts['GDS-V3'], 8, 'two per file across four files');
207
+ assert.equal(observed.versionIdCounts['GDS-V4'], 4);
208
+ });
209
+
210
+ test('observe skips a binary file instead of guessing at its contents', () => {
211
+ const observed = check.observe({
212
+ files: ['docs/adr/logo.png'],
213
+ readFile: () => `${String.fromCharCode(0)}${LITERAL}`,
214
+ aliasMounts: ['/api/gds'],
215
+ });
216
+ assert.deepEqual(observed.frozenLiteralCounts, {});
217
+ });
218
+
219
+ // ---------- the committed baseline, and today's tree ----------
220
+
221
+ test('the committed baseline pins the two migrations that literally carry the old name', () => {
222
+ const baseline = check.loadBaseline();
223
+ for (const p of [
224
+ 'migrations/019_rename_pms_v3_to_gds_v3.sql',
225
+ 'modules/economy/migrations/economy_001_fix_gds_shipper_description.sql',
226
+ ]) {
227
+ assert.ok(baseline.migrationFiles.includes(p), `baseline must pin ${p} — it sits directly in a codemod's path`);
228
+ }
229
+ assert.ok(baseline.migrationFiles.length > 100, 'every migration filename is pinned, not a sample');
230
+ assert.ok(Object.keys(baseline.frozenLiteralFloors).length > 100, 'the frozen record is pinned per file');
231
+ });
232
+
233
+ test('today’s tree passes — the cheap half, asserted last', () => {
234
+ const report = check.runAll({ observed: check.observe(), baseline: check.loadBaseline() });
235
+ assert.equal(report.ok, true, check.describe(report).join('\n'));
236
+ });