@bongos/core 1.19.916 → 1.19.918
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +54 -24
- package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +10 -5
- package/docs/adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md +148 -0
- package/docs/adr/0318-listing-a-file-for-task-affinity-is-not-owning-it.md +101 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +1 -1
- package/migrations/core_252_claims_published_head_sha_idx.sql +33 -0
- package/modules/lifecycle/cascade.js +92 -4
- package/modules/lifecycle/db-ship.js +43 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/removal-derivation.js +332 -0
- package/modules/lifecycle/routes/lifecycle.js +24 -0
- package/modules/lifecycle/tests/cascade.mjs +21 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo.js +52 -0
- package/scripts/gds/provision.js +7 -1
- package/src/bongos/module-scope-map.js +14 -4
- package/src/module-api.js +1 -1
- package/tests/module-scope-map.mjs +100 -7
- package/tests/provision_account_identity.mjs +135 -0
- package/tests/removal_proposal.mjs +583 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// tests/removal_proposal.mjs — BV2.R15: a ship that deletes earlier shipped work
|
|
2
|
+
// files a QUESTION (task 1004090, ADR 0313).
|
|
3
|
+
//
|
|
4
|
+
// The assertions that matter here are the NEGATIVE ones. This derivation moves
|
|
5
|
+
// nothing on its own — it must not mark work removed, must not touch a status or
|
|
6
|
+
// a credit, and must not fail a ship. Most of what follows exists to fail loudly
|
|
7
|
+
// if a later change turns the proposal into a verdict.
|
|
8
|
+
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
16
|
+
|
|
17
|
+
const cascade = require(path.join(ROOT, 'modules/lifecycle/cascade.js'));
|
|
18
|
+
const removal = require(path.join(ROOT, 'modules/lifecycle/removal-derivation.js'));
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
async function test(name, fn) {
|
|
23
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
24
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RULE_KEY = 'removal-proposed-on-ship';
|
|
28
|
+
const rule = cascade.CASCADES.find((r) => r.key === RULE_KEY);
|
|
29
|
+
|
|
30
|
+
// --- the three exclusions ADR 0313 names -----------------------------------
|
|
31
|
+
|
|
32
|
+
await test('only a deletion counts — a rename is not a removal', () => {
|
|
33
|
+
// The exclusion that matters most: a directory reorganisation would otherwise
|
|
34
|
+
// propose a removal for every file it moved.
|
|
35
|
+
const files = [
|
|
36
|
+
{ path: 'a/moved.js', status: 'renamed', deleted: false },
|
|
37
|
+
{ path: 'a/gone.js', status: 'removed', deleted: true },
|
|
38
|
+
];
|
|
39
|
+
assert.deepEqual(removal.deletedPathsFrom(files), ['a/gone.js']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await test('a rewrite in place is not a removal, however many lines went', () => {
|
|
43
|
+
// Where a `removed > added` line-count heuristic goes wrong.
|
|
44
|
+
const files = [{ path: 'a/rewritten.js', status: 'modified', deleted: false, additions: 2, deletions: 900 }];
|
|
45
|
+
assert.deepEqual(removal.deletedPathsFrom(files), []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await test('added, copied and changed carry no loss', () => {
|
|
49
|
+
for (const status of ['added', 'copied', 'changed']) {
|
|
50
|
+
assert.deepEqual(removal.deletedPathsFrom([{ path: 'x.js', status }]), [], `status ${status} leaked`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await test('status and the deleted flag must agree; status wins when both are present', () => {
|
|
55
|
+
// A caller that hand-set `deleted` must not be able to invent a removal, and a
|
|
56
|
+
// caller that shaped the row the other way must not have one missed.
|
|
57
|
+
assert.deepEqual(removal.deletedPathsFrom([{ path: 'x.js', status: 'renamed', deleted: true }]), []);
|
|
58
|
+
assert.deepEqual(removal.deletedPathsFrom([{ path: 'x.js', deleted: true }]), ['x.js']);
|
|
59
|
+
assert.deepEqual(removal.deletedPathsFrom([{ path: 'x.js', deleted: false }]), []);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('malformed rows are skipped rather than throwing into the ship path', () => {
|
|
63
|
+
const files = [null, 'nope', {}, { status: 'removed' }, { path: '', status: 'removed' }, { path: 'ok.js', status: 'removed' }];
|
|
64
|
+
assert.deepEqual(removal.deletedPathsFrom(files), ['ok.js']);
|
|
65
|
+
assert.deepEqual(removal.deletedPathsFrom(null), []);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// --- attribution ------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const lookupFrom = (map) => async (shas) => {
|
|
71
|
+
const out = new Map();
|
|
72
|
+
for (const s of shas) if (map[s]) out.set(s, map[s]);
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await test('a deleted path is attributed to the task that published it', async () => {
|
|
77
|
+
const { attributions } = await removal.attributeDeletions(
|
|
78
|
+
{ paths: ['a/gone.js'], shipTaskId: '999' },
|
|
79
|
+
{
|
|
80
|
+
listCommitsForPath: async () => ['shaNew', 'shaOld'],
|
|
81
|
+
shippedTasksByPublishedSha: lookupFrom({ shaOld: { taskId: '1003944', title: 'The old scorer' } }),
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
assert.equal(attributions.length, 1);
|
|
85
|
+
assert.equal(attributions[0].taskId, '1003944');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await test('the OLDEST matching commit wins — not whoever last edited the file', async () => {
|
|
89
|
+
// The newest would answer a different and wrong question: who touched it, not
|
|
90
|
+
// whose work it was.
|
|
91
|
+
const { attributions } = await removal.attributeDeletions(
|
|
92
|
+
{ paths: ['a/gone.js'], shipTaskId: '999' },
|
|
93
|
+
{
|
|
94
|
+
// API order is newest-first.
|
|
95
|
+
listCommitsForPath: async () => ['shaLate', 'shaEarly'],
|
|
96
|
+
shippedTasksByPublishedSha: lookupFrom({
|
|
97
|
+
shaLate: { taskId: '1003999', title: 'A later touch' },
|
|
98
|
+
shaEarly: { taskId: '1003944', title: 'The one that introduced it' },
|
|
99
|
+
}),
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
assert.equal(attributions[0].taskId, '1003944');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await test('a ship deleting its OWN earlier file attributes nothing', async () => {
|
|
106
|
+
// Tidying up inside one piece of work is not a removal of someone else's.
|
|
107
|
+
const { attributions } = await removal.attributeDeletions(
|
|
108
|
+
{ paths: ['a/gone.js'], shipTaskId: '1003944' },
|
|
109
|
+
{
|
|
110
|
+
listCommitsForPath: async () => ['shaOld'],
|
|
111
|
+
shippedTasksByPublishedSha: lookupFrom({ shaOld: { taskId: '1003944', title: 'Mine' } }),
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
assert.deepEqual(attributions, []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('an UNATTRIBUTABLE deletion attributes nothing — silence, not a guess', async () => {
|
|
118
|
+
// published_head_sha is nullable. ADR 0313: a wrong removal proposal costs a
|
|
119
|
+
// person's attention and dims a real star, so absence must stay silent.
|
|
120
|
+
const { attributions } = await removal.attributeDeletions(
|
|
121
|
+
{ paths: ['a/gone.js'], shipTaskId: '999' },
|
|
122
|
+
{ listCommitsForPath: async () => ['unknownSha'], shippedTasksByPublishedSha: lookupFrom({}) }
|
|
123
|
+
);
|
|
124
|
+
assert.deepEqual(attributions, []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await test('one unreadable path costs only itself', async () => {
|
|
128
|
+
const { attributions, errors } = await removal.attributeDeletions(
|
|
129
|
+
{ paths: ['bad.js', 'good.js'], shipTaskId: '999' },
|
|
130
|
+
{
|
|
131
|
+
listCommitsForPath: async ({ path: p }) => {
|
|
132
|
+
if (p === 'bad.js') throw new Error('boom');
|
|
133
|
+
return ['shaOld'];
|
|
134
|
+
},
|
|
135
|
+
shippedTasksByPublishedSha: lookupFrom({ shaOld: { taskId: '1003944', title: 'T' } }),
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
assert.equal(attributions.length, 1);
|
|
139
|
+
assert.equal(attributions[0].path, 'good.js');
|
|
140
|
+
assert.ok(errors.some((e) => e.includes('bad.js')));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await test('attribution is capped, and the cap is on attribution not detection', async () => {
|
|
144
|
+
const paths = Array.from({ length: removal.MAX_ATTRIBUTED_PATHS + 10 }, (_, i) => `f${i}.js`);
|
|
145
|
+
let calls = 0;
|
|
146
|
+
await removal.attributeDeletions(
|
|
147
|
+
{ paths, shipTaskId: '999' },
|
|
148
|
+
{ listCommitsForPath: async () => { calls += 1; return []; }, shippedTasksByPublishedSha: lookupFrom({}) }
|
|
149
|
+
);
|
|
150
|
+
assert.equal(calls, removal.MAX_ATTRIBUTED_PATHS);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --- the GitHub call budget -------------------------------------------------
|
|
154
|
+
//
|
|
155
|
+
// This derivation runs on the ship path and spends the SERVER'S SHARED push
|
|
156
|
+
// credential — the same one the publish path depends on. Every assertion here is
|
|
157
|
+
// about not spending more of it than the question is worth.
|
|
158
|
+
|
|
159
|
+
await test('a rate-limit refusal ABANDONS the walk instead of spending the rest', async () => {
|
|
160
|
+
// The point of the fix: continuing would spend the remaining budget on requests
|
|
161
|
+
// already known to fail, which is the opposite of backing off.
|
|
162
|
+
let calls = 0;
|
|
163
|
+
const { attributions, errors } = await removal.attributeDeletions(
|
|
164
|
+
{ paths: ['a.js', 'b.js', 'c.js', 'd.js'], shipTaskId: '999' },
|
|
165
|
+
{
|
|
166
|
+
listCommitsForPath: async () => {
|
|
167
|
+
calls += 1;
|
|
168
|
+
const e = new Error('RATE_LIMITED');
|
|
169
|
+
e.rateLimited = true;
|
|
170
|
+
throw e;
|
|
171
|
+
},
|
|
172
|
+
shippedTasksByPublishedSha: lookupFrom({}),
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
assert.equal(calls, 1, 'the walk must stop at the FIRST rate-limit signal');
|
|
176
|
+
assert.deepEqual(attributions, []);
|
|
177
|
+
assert.ok(errors.some((e) => /rate limit/i.test(e)), 'and it must say so rather than failing silently');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('an ORDINARY per-path failure does NOT abandon the walk', async () => {
|
|
181
|
+
// The other half: only a rate limit is a statement about the whole credential.
|
|
182
|
+
// Treating every error as fatal would lose attributions to one bad path.
|
|
183
|
+
let calls = 0;
|
|
184
|
+
const { attributions } = await removal.attributeDeletions(
|
|
185
|
+
{ paths: ['bad.js', 'good.js'], shipTaskId: '999' },
|
|
186
|
+
{
|
|
187
|
+
listCommitsForPath: async ({ path: pth }) => {
|
|
188
|
+
calls += 1;
|
|
189
|
+
if (pth === 'bad.js') throw new Error('STATUS_FETCH_FAILED 500');
|
|
190
|
+
return ['shaOld'];
|
|
191
|
+
},
|
|
192
|
+
shippedTasksByPublishedSha: lookupFrom({ shaOld: { taskId: '1003944', title: 'T' } }),
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
assert.equal(calls, 2);
|
|
196
|
+
assert.equal(attributions.length, 1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await test('the call budget is small enough to be worth spending', async () => {
|
|
200
|
+
// A number, pinned, because the whole finding was about how many requests one
|
|
201
|
+
// ship can fire. Raising it is a deliberate act that should fail a test first.
|
|
202
|
+
assert.equal(removal.MAX_ATTRIBUTED_PATHS, 8);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('a rate-limited derivation still files nothing and never throws', async () => {
|
|
206
|
+
const r = await removal.deriveRemovalsForShip(
|
|
207
|
+
{ taskId: '1', prNumber: 7 },
|
|
208
|
+
{
|
|
209
|
+
getPullRequestFiles: async () => [
|
|
210
|
+
{ path: 'a.js', status: 'removed', deleted: true },
|
|
211
|
+
{ path: 'b.js', status: 'removed', deleted: true },
|
|
212
|
+
],
|
|
213
|
+
listCommitsForPath: async () => { const e = new Error('RATE_LIMITED'); e.rateLimited = true; throw e; },
|
|
214
|
+
shippedTasksByPublishedSha: lookupFrom({}),
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
assert.deepEqual(r.removals, []);
|
|
218
|
+
assert.equal(r.deletedCount, 2, 'detection is honest even when attribution was abandoned');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test('listCommitsForPath names a rate limit apart from an ordinary 403', async () => {
|
|
222
|
+
// A 403 for any other reason keeps a non-zero remaining (or sends no header),
|
|
223
|
+
// and must stay an ordinary failure — otherwise a permissions problem would
|
|
224
|
+
// silently abandon every future walk as if it were throttling.
|
|
225
|
+
const res = (status, remaining) => ({
|
|
226
|
+
status, ok: false,
|
|
227
|
+
headers: { get: (h) => (h === 'x-ratelimit-remaining' ? remaining : null) },
|
|
228
|
+
json: async () => [],
|
|
229
|
+
});
|
|
230
|
+
const call = (r) => removal.listCommitsForPath(
|
|
231
|
+
{ path: 'a.js' },
|
|
232
|
+
{ resolveToken: async () => 'tok', repoInfo: { owner: 'o', repo: 'r' }, fetchImpl: async () => r }
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
await assert.rejects(() => call(res(403, '0')), (e) => e.rateLimited === true);
|
|
236
|
+
await assert.rejects(() => call(res(429, '55')), (e) => e.rateLimited === true);
|
|
237
|
+
await assert.rejects(() => call(res(403, '55')), (e) => !e.rateLimited);
|
|
238
|
+
await assert.rejects(() => call(res(403, null)), (e) => !e.rateLimited);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// --- the whole derivation is non-blocking ----------------------------------
|
|
242
|
+
|
|
243
|
+
await test('no pull request derives nothing and does not throw', async () => {
|
|
244
|
+
const r = await removal.deriveRemovalsForShip({ taskId: '1', prNumber: null });
|
|
245
|
+
assert.deepEqual(r.removals, []);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('an unreadable PR derives nothing and does not throw', async () => {
|
|
249
|
+
const r = await removal.deriveRemovalsForShip(
|
|
250
|
+
{ taskId: '1', prNumber: 7 },
|
|
251
|
+
{ getPullRequestFiles: async () => { throw new Error('GitHub down'); } }
|
|
252
|
+
);
|
|
253
|
+
assert.deepEqual(r.removals, []);
|
|
254
|
+
assert.ok(r.errors.length);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await test('a sha lookup that throws derives nothing and does not throw', async () => {
|
|
258
|
+
const r = await removal.deriveRemovalsForShip(
|
|
259
|
+
{ taskId: '1', prNumber: 7 },
|
|
260
|
+
{
|
|
261
|
+
getPullRequestFiles: async () => [{ path: 'a.js', status: 'removed', deleted: true }],
|
|
262
|
+
listCommitsForPath: async () => ['s1'],
|
|
263
|
+
shippedTasksByPublishedSha: async () => { throw new Error('db down'); },
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
assert.deepEqual(r.removals, []);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await test('a ship that deleted nothing reports no error at all', async () => {
|
|
270
|
+
// The overwhelmingly common case. It must be quiet, not merely harmless.
|
|
271
|
+
const r = await removal.deriveRemovalsForShip(
|
|
272
|
+
{ taskId: '1', prNumber: 7 },
|
|
273
|
+
{ getPullRequestFiles: async () => [{ path: 'a.js', status: 'modified' }] }
|
|
274
|
+
);
|
|
275
|
+
assert.deepEqual(r.removals, []);
|
|
276
|
+
assert.deepEqual(r.errors, []);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await test('the end-to-end derivation groups paths under their task', async () => {
|
|
280
|
+
const r = await removal.deriveRemovalsForShip(
|
|
281
|
+
{ taskId: '999', prNumber: 7 },
|
|
282
|
+
{
|
|
283
|
+
getPullRequestFiles: async () => [
|
|
284
|
+
{ path: 'a/one.js', status: 'removed', deleted: true },
|
|
285
|
+
{ path: 'a/two.js', status: 'removed', deleted: true },
|
|
286
|
+
{ path: 'a/moved.js', status: 'renamed', deleted: false },
|
|
287
|
+
],
|
|
288
|
+
listCommitsForPath: async () => ['shaOld'],
|
|
289
|
+
shippedTasksByPublishedSha: lookupFrom({ shaOld: { taskId: '1003944', title: 'The old scorer' } }),
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
assert.equal(r.removals.length, 1);
|
|
293
|
+
assert.deepEqual(r.removals[0].paths, ['a/one.js', 'a/two.js']);
|
|
294
|
+
assert.equal(r.deletedCount, 2, 'the renamed file must not be counted');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// --- THE WIRING, which is where the dead feature lived ----------------------
|
|
298
|
+
//
|
|
299
|
+
// The first two rounds of this task shipped a rule that could never fire: the
|
|
300
|
+
// resolver read `pr.number` off findTaskPullRequest, which returns `pr_number`,
|
|
301
|
+
// so prNumber was null on every real ship and the derivation short-circuited
|
|
302
|
+
// every time. Thirty-three tests passed over it, because every one of them
|
|
303
|
+
// injected `removalCandidates` or `getPullRequestFiles` and none was wired to
|
|
304
|
+
// the part that was wrong.
|
|
305
|
+
//
|
|
306
|
+
// These drive the REAL resolver. They are the tests that would have caught it.
|
|
307
|
+
|
|
308
|
+
await test('the resolver reads pr_number — the field findTaskPullRequest ACTUALLY returns', async () => {
|
|
309
|
+
// The blocker, pinned. Reading the wrong field is SILENT: it yields null,
|
|
310
|
+
// which is a legal "no PR" answer, so the feature just never runs.
|
|
311
|
+
let sawPrNumber = null;
|
|
312
|
+
const r = await removal.removalCandidatesForTask(
|
|
313
|
+
{ id: '7002' },
|
|
314
|
+
{
|
|
315
|
+
// Exactly the shape github-push.js:855 returns.
|
|
316
|
+
findTaskPullRequest: async () => ({ branch: 'b', pr_number: 1046, pr_state: 'open', merged: true }),
|
|
317
|
+
getPullRequestFiles: async ({ number }) => {
|
|
318
|
+
sawPrNumber = number;
|
|
319
|
+
return [{ path: 'a/gone.js', status: 'removed', deleted: true }];
|
|
320
|
+
},
|
|
321
|
+
listCommitsForPath: async () => ['shaOld'],
|
|
322
|
+
shippedTasksByPublishedSha: lookupFrom({ shaOld: { taskId: '1003944', title: 'The old scorer' } }),
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
assert.equal(sawPrNumber, 1046, 'the PR number never reached the files read');
|
|
326
|
+
assert.equal(r.removals.length, 1, 'a real deletion must produce a candidate');
|
|
327
|
+
assert.equal(r.removals[0].taskId, '1003944');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await test('a PR shape carrying only .number yields NOTHING — no silent alternative field', async () => {
|
|
331
|
+
// Guards the fix from being "fixed" back by someone adding a || pr.number
|
|
332
|
+
// fallback: that would resurrect the bug as a tolerated shape.
|
|
333
|
+
const r = await removal.removalCandidatesForTask(
|
|
334
|
+
{ id: '7002' },
|
|
335
|
+
{
|
|
336
|
+
findTaskPullRequest: async () => ({ number: 1046 }),
|
|
337
|
+
getPullRequestFiles: async () => { throw new Error('must not be reached'); },
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
assert.deepEqual(r.removals, []);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await test('no PR at all derives nothing and does not throw', async () => {
|
|
344
|
+
const r = await removal.removalCandidatesForTask(
|
|
345
|
+
{ id: '7002' },
|
|
346
|
+
{ findTaskPullRequest: async () => null }
|
|
347
|
+
);
|
|
348
|
+
assert.deepEqual(r.removals, []);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await test('a PR lookup that throws costs the ship nothing', async () => {
|
|
352
|
+
const r = await removal.removalCandidatesForTask(
|
|
353
|
+
{ id: '7002' },
|
|
354
|
+
{ findTaskPullRequest: async () => { throw new Error('GitHub down'); } }
|
|
355
|
+
);
|
|
356
|
+
assert.deepEqual(r.removals, []);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
await test('the LISTENER wires the named resolver, not a closure of its own', async () => {
|
|
360
|
+
// Structural, and it is the lesson rather than the symptom: a closure inside
|
|
361
|
+
// the listener registration is unreachable from a test, which is exactly how
|
|
362
|
+
// the wrong field name survived two grading rounds. If someone inlines it
|
|
363
|
+
// again, this names the file before the feature silently dies again.
|
|
364
|
+
const src = await import('node:fs').then((m) => m.readFileSync(
|
|
365
|
+
path.join(ROOT, 'modules/lifecycle/routes/lifecycle.js'), 'utf8'
|
|
366
|
+
));
|
|
367
|
+
assert.match(src, /removalCandidates:\s*\(task\)\s*=>\s*removalDerivation\.removalCandidatesForTask/);
|
|
368
|
+
// codeOnly, because the listener's comment legitimately NAMES the function it
|
|
369
|
+
// no longer calls — the third time in this task a raw source scan matched its
|
|
370
|
+
// own explanatory prose. See the stripper's own header.
|
|
371
|
+
assert.ok(!/findTaskPullRequest/.test(codeOnly(src)), 'the PR lookup belongs in the tested resolver, not the listener');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// --- the rule itself --------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
await test('the rule is declared and the table still validates', () => {
|
|
377
|
+
assert.ok(rule, `${RULE_KEY} is not in CASCADES`);
|
|
378
|
+
assert.deepEqual(cascade.validateCascades(), []);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await test('the anti-recursion guard holds — a cascade task never cascades', () => {
|
|
382
|
+
const t = { id: 5, source: cascade.CASCADE_SOURCE };
|
|
383
|
+
assert.equal(rule.when(t, { removals: [{ taskId: '1', paths: ['a'] }] }), false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
await test('NO derived removals files nothing', () => {
|
|
387
|
+
const t = { id: 5, source: 'builder' };
|
|
388
|
+
assert.equal(rule.when(t, { removals: [] }), false);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await test('an ABSENT context files nothing — silence is never read as a removal', () => {
|
|
392
|
+
// The opposite default to the artist gate, deliberately. A derivation that did
|
|
393
|
+
// not run must not be able to propose that work was destroyed.
|
|
394
|
+
const t = { id: 5, source: 'builder' };
|
|
395
|
+
assert.equal(rule.when(t, {}), false);
|
|
396
|
+
assert.equal(rule.when(t, undefined), false);
|
|
397
|
+
assert.equal(rule.when(t, { removals: null }), false);
|
|
398
|
+
assert.equal(rule.when(t, { removals: 'yes' }), false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await test('derived removals DO file', () => {
|
|
402
|
+
const t = { id: 5, source: 'builder' };
|
|
403
|
+
assert.equal(rule.when(t, { removals: [{ taskId: '1003944', paths: ['a.js'] }] }), true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// --- it asks; it does not tell ---------------------------------------------
|
|
407
|
+
|
|
408
|
+
const ctx = {
|
|
409
|
+
removals: [{ taskId: '1003944', title: 'The old scorer', paths: ['modules/foo/old-scorer.js'] }],
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
await test('the proposal names the task and the path it lost', () => {
|
|
413
|
+
const body = cascade.removalProposalBody({ id: 1004090 }, ctx);
|
|
414
|
+
assert.match(body, /task 1003944/);
|
|
415
|
+
assert.match(body, /The old scorer/);
|
|
416
|
+
assert.match(body, /modules\/foo\/old-scorer\.js/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await test('the proposal SAYS it is a question and that nothing has moved', () => {
|
|
420
|
+
// The done-when in one assertion: a question, not a verdict.
|
|
421
|
+
const body = cascade.removalProposalBody({ id: 1004090 }, ctx);
|
|
422
|
+
assert.match(body, /question, not a verdict/i);
|
|
423
|
+
assert.match(body, /no\s+status changed and no credit moved/i);
|
|
424
|
+
assert.match(body, /`shipped` is terminal/);
|
|
425
|
+
assert.match(body, /\*\*Dismiss\*\*/);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await test('the proposal says when its own list is partial', () => {
|
|
429
|
+
const body = cascade.removalProposalBody({ id: 1 }, { ...ctx, removalTruncated: true, removalDeletedCount: 90 });
|
|
430
|
+
assert.match(body, /PARTIAL/);
|
|
431
|
+
assert.match(body, /90/);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await test('the generated task is a backlog REQUEST carrying the preamble', () => {
|
|
435
|
+
// Filing at backlog is what makes confirming a promote on the existing
|
|
436
|
+
// awaiting-nod surface (R16) rather than a new page.
|
|
437
|
+
const spec = cascade.cascadeTaskSpec(rule, { id: 7, version_id: 'BONGOS-V2', goal_id: 1000095, source: 'builder' }, cascade.TRIGGERS.ship, ctx);
|
|
438
|
+
assert.equal(spec.status, 'backlog');
|
|
439
|
+
assert.equal(spec.source, cascade.CASCADE_SOURCE);
|
|
440
|
+
assert.match(spec.description, /it is a REQUEST/);
|
|
441
|
+
assert.match(spec.description, /task 1003944/);
|
|
442
|
+
assert.equal(spec.discipline, 'engineer');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await test('cascadesFor threads ctx into the body, not just the predicate', () => {
|
|
446
|
+
// The bug this pins: `when` seeing the removals while `body` did not would file
|
|
447
|
+
// a proposal with an EMPTY list — a question nobody can answer.
|
|
448
|
+
const { matched, errors } = cascade.cascadesFor(
|
|
449
|
+
{ id: 7, version_id: 'BONGOS-V2', goal_id: 1000095, source: 'builder' },
|
|
450
|
+
[rule],
|
|
451
|
+
ctx
|
|
452
|
+
);
|
|
453
|
+
assert.deepEqual(errors, []);
|
|
454
|
+
assert.equal(matched.length, 1);
|
|
455
|
+
assert.match(matched[0].spec.description, /modules\/foo\/old-scorer\.js/);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Strip comments before scanning source for forbidden constructs.
|
|
459
|
+
//
|
|
460
|
+
// WHY THIS EXISTS AS A HELPER. The first cut of the test below scanned the raw
|
|
461
|
+
// file and failed on the word "credit" — inside a COMMENT explaining that this
|
|
462
|
+
// derivation must never touch credits. That is the same defect that redded
|
|
463
|
+
// task 1004083 in CI (a comment reading "on purpose" matched a scan for the
|
|
464
|
+
// template field key "purpose"): a substring scan over source is a scan over
|
|
465
|
+
// prose unless you say otherwise.
|
|
466
|
+
//
|
|
467
|
+
// Rewording the comment would have hidden the bug rather than fixed it, and
|
|
468
|
+
// would have left the next honest comment to trip the same wire. Strings are
|
|
469
|
+
// left in place deliberately — a forbidden construct inside a string literal is
|
|
470
|
+
// still worth failing on.
|
|
471
|
+
function codeOnly(src) {
|
|
472
|
+
return src
|
|
473
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
474
|
+
.split('\n')
|
|
475
|
+
.map((line) => line.replace(/(^|[^:])\/\/.*$/, '$1'))
|
|
476
|
+
.join('\n');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await test('the comment stripper actually strips — otherwise the scan below is vacuous', () => {
|
|
480
|
+
// A scanner breaks by quietly finding nothing, and green looks identical to
|
|
481
|
+
// working. So the stripper is tested before anything relies on it.
|
|
482
|
+
assert.ok(!codeOnly('// credit\nconst a = 1;').includes('credit'));
|
|
483
|
+
assert.ok(!codeOnly('/* credit */ const a = 1;').includes('credit'));
|
|
484
|
+
assert.ok(codeOnly('const credit = 1; // note').includes('credit'), 'stripped real code');
|
|
485
|
+
assert.ok(codeOnly("const u = 'https://x';").includes('https://x'), 'ate a URL');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await test('nothing in the proposal path changes a status or a credit', () => {
|
|
489
|
+
// Structural. The derivation is read-only by construction; if it ever grows a
|
|
490
|
+
// write, this names the file.
|
|
491
|
+
const src = codeOnly(require('node:fs').readFileSync(path.join(ROOT, 'modules/lifecycle/removal-derivation.js'), 'utf8'));
|
|
492
|
+
for (const forbidden of ['UPDATE ', 'INSERT ', 'credit', 'shipped_at', "status = '"]) {
|
|
493
|
+
assert.ok(!src.includes(forbidden), `removal-derivation.js contains "${forbidden}" in CODE — it must stay read-only`);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// --- the RUNNER, end to end ------------------------------------------------
|
|
498
|
+
//
|
|
499
|
+
// Everything above tests a part. These drive runCascadesForShip itself, because
|
|
500
|
+
// the parts agreeing with each other proves nothing about the path that actually
|
|
501
|
+
// files the task — and the ctx plumbing between the resolver, the predicate and
|
|
502
|
+
// the body is exactly where a half-wired feature hides.
|
|
503
|
+
|
|
504
|
+
const shippedTask = (over = {}) => ({
|
|
505
|
+
id: '7002', version_id: 'BONGOS-V2', goal_id: '1000095',
|
|
506
|
+
title: 'Delete the old scorer', discipline: 'engineer',
|
|
507
|
+
kind: 'feature', status: 'shipped', source: 'manual', ...over,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
function runnerDeps(over = {}) {
|
|
511
|
+
const created = [];
|
|
512
|
+
return {
|
|
513
|
+
created,
|
|
514
|
+
deps: {
|
|
515
|
+
getTask: async () => shippedTask(),
|
|
516
|
+
findTaskBySourceRef: async () => null,
|
|
517
|
+
createTask: async (spec) => { created.push(spec); return { id: '8001' }; },
|
|
518
|
+
...over,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
await test('a derived removal files ONE proposal naming the task and path', async () => {
|
|
524
|
+
const { created, deps } = runnerDeps({
|
|
525
|
+
removalCandidates: async () => ({
|
|
526
|
+
removals: [{ taskId: '1003944', title: 'The old scorer', paths: ['modules/foo/old-scorer.js'] }],
|
|
527
|
+
deletedCount: 1, truncated: false, errors: [],
|
|
528
|
+
}),
|
|
529
|
+
});
|
|
530
|
+
const report = await cascade.runCascadesForShip({ id: '7002' }, deps);
|
|
531
|
+
const proposals = created.filter((c) => c.sourceRef.includes(RULE_KEY));
|
|
532
|
+
assert.equal(proposals.length, 1);
|
|
533
|
+
assert.match(proposals[0].description, /task 1003944/);
|
|
534
|
+
assert.match(proposals[0].description, /modules\/foo\/old-scorer\.js/);
|
|
535
|
+
assert.equal(proposals[0].status, 'backlog');
|
|
536
|
+
assert.deepEqual(report.errors, []);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await test('NO removalCandidates dep at all files no proposal, and no error', async () => {
|
|
540
|
+
// The state every instance is in until the listener is wired, and the state a
|
|
541
|
+
// test rebuilding the router lands in. It must be quiet.
|
|
542
|
+
const { created, deps } = runnerDeps();
|
|
543
|
+
const report = await cascade.runCascadesForShip({ id: '7002' }, deps);
|
|
544
|
+
assert.equal(created.filter((c) => c.sourceRef.includes(RULE_KEY)).length, 0);
|
|
545
|
+
assert.deepEqual(report.errors, []);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await test('a resolver that THROWS costs the ship nothing', async () => {
|
|
549
|
+
// ADR 0313: landing work matters more than dimming a star on time.
|
|
550
|
+
const { created, deps } = runnerDeps({
|
|
551
|
+
removalCandidates: async () => { throw new Error('GitHub exploded'); },
|
|
552
|
+
});
|
|
553
|
+
const report = await cascade.runCascadesForShip({ id: '7002' }, deps);
|
|
554
|
+
assert.equal(created.filter((c) => c.sourceRef.includes(RULE_KEY)).length, 0);
|
|
555
|
+
assert.ok(report.errors.some((e) => /removalCandidates/.test(e)), 'the failure must be reported, not swallowed silently');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await test('the proposal is idempotent — a second run files nothing new', async () => {
|
|
559
|
+
const { created, deps } = runnerDeps({
|
|
560
|
+
removalCandidates: async () => ({
|
|
561
|
+
removals: [{ taskId: '1003944', title: 'T', paths: ['a.js'] }], deletedCount: 1, truncated: false, errors: [],
|
|
562
|
+
}),
|
|
563
|
+
findTaskBySourceRef: async () => ({ id: '8001' }),
|
|
564
|
+
});
|
|
565
|
+
const report = await cascade.runCascadesForShip({ id: '7002' }, deps);
|
|
566
|
+
assert.equal(created.length, 0);
|
|
567
|
+
assert.ok(report.skipped.some((sk) => sk.key === RULE_KEY));
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
await test('the derivation errors ride into the report without blocking the filing', async () => {
|
|
571
|
+
const { created, deps } = runnerDeps({
|
|
572
|
+
removalCandidates: async () => ({
|
|
573
|
+
removals: [{ taskId: '1003944', title: 'T', paths: ['a.js'] }],
|
|
574
|
+
deletedCount: 3, truncated: false, errors: ['removal: history for b.js unreadable (boom)'],
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
const report = await cascade.runCascadesForShip({ id: '7002' }, deps);
|
|
578
|
+
assert.equal(created.filter((c) => c.sourceRef.includes(RULE_KEY)).length, 1, 'a partial read still files what it DID find');
|
|
579
|
+
assert.ok(report.errors.some((e) => e.includes('b.js')));
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
console.log(`\nremoval_proposal: ${passed} passed, ${failed} failed`);
|
|
583
|
+
process.exit(failed ? 1 : 0);
|