@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
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// shipped task looks like THIS, file THAT task, for THAT discipline. There is no
|
|
18
18
|
// condition language, no stored rules, no per-project configuration and no UI —
|
|
19
19
|
// every one of those is a thing to add when a second and third cascade exist and
|
|
20
|
-
// have shown what they have in common.
|
|
20
|
+
// have shown what they have in common. `CASCADES` no longer ships empty: the
|
|
21
21
|
// mechanism, its idempotency and its non-blocking property are proved by the
|
|
22
22
|
// tests' own declarations, and R04 (task 1003115) writes the first production
|
|
23
23
|
// entry. A mechanism whose first real rule is written by the task that needs it
|
|
@@ -169,6 +169,50 @@ function artistReviewBody(task) {
|
|
|
169
169
|
].join('\n');
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// The removal proposal's body (task 1004090 / BV2.R15, ADR 0313).
|
|
173
|
+
//
|
|
174
|
+
// IT ASKS, IT DOES NOT TELL. Every line here is written so a reader confirms or
|
|
175
|
+
// dismisses on the evidence rather than rubber-stamping a verdict — because the
|
|
176
|
+
// owner chose propose-then-confirm precisely for the cases this derivation cannot
|
|
177
|
+
// tell apart from a real removal. The three it CAN tell apart (a rename, a move,
|
|
178
|
+
// a rewrite in place) are already excluded upstream in removal-derivation.js.
|
|
179
|
+
//
|
|
180
|
+
// The affected tasks and paths are named as PROSE, deliberately. A cascade files
|
|
181
|
+
// one task per rule per ship keyed on (rule, ship), with no field for a target,
|
|
182
|
+
// and R05's runner may not be widened to fan out (ADR 0242). Owner decision
|
|
183
|
+
// 2026-09-20: the body carries them and R20 designs its own storage when it needs
|
|
184
|
+
// one. So this text IS the record until then — keep it readable and keep it exact.
|
|
185
|
+
function removalProposalBody(task, ctx) {
|
|
186
|
+
const found = ((ctx || {}).removals) || [];
|
|
187
|
+
const lines = [];
|
|
188
|
+
lines.push(`Shipping task ${task.id} deleted files that earlier SHIPPED work put there.`);
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push('**This is a question, not a verdict.** Nothing has been marked removed, no');
|
|
191
|
+
lines.push('status changed and no credit moved — and none of that happens unless a person');
|
|
192
|
+
lines.push('confirms below. `shipped` is terminal and stays terminal.');
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push('**Removed work attributed to:**');
|
|
195
|
+
for (const r of found) {
|
|
196
|
+
lines.push(` - task ${r.taskId}${r.title ? ` — ${r.title}` : ''}`);
|
|
197
|
+
for (const path of r.paths) lines.push(` ${path}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push('');
|
|
200
|
+
if ((ctx || {}).removalTruncated) {
|
|
201
|
+
lines.push(`Note: this ship deleted ${(ctx || {}).removalDeletedCount} paths, more than the`);
|
|
202
|
+
lines.push('attribution cap, so the list above is PARTIAL. A ship that large is usually a');
|
|
203
|
+
lines.push('move or a sweep — weigh that before confirming.');
|
|
204
|
+
lines.push('');
|
|
205
|
+
}
|
|
206
|
+
lines.push('**Confirm** if that work is genuinely gone: the ideas whose descent it belongs');
|
|
207
|
+
lines.push('to will dim accordingly. **Dismiss** if this was a refactor, a move the rename');
|
|
208
|
+
lines.push('detection missed, or a deletion of something already superseded — dismissing is');
|
|
209
|
+
lines.push('what stops the sky being dimmed for a reason that is not real.');
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push('Attribution runs through `claims.published_head_sha`, so a deletion whose origin');
|
|
212
|
+
lines.push('predates that record is silently absent rather than guessed at.');
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
172
216
|
// Production rules. The artist review is the first (R04); add another by
|
|
173
217
|
// appending an entry, never by touching the runner (ADR 0242).
|
|
174
218
|
const CASCADES = Object.freeze([
|
|
@@ -189,6 +233,25 @@ const CASCADES = Object.freeze([
|
|
|
189
233
|
title: (t) => `Artist review: the copy and visuals that shipped in task ${t.id}`,
|
|
190
234
|
body: artistReviewBody,
|
|
191
235
|
}),
|
|
236
|
+
Object.freeze({
|
|
237
|
+
key: 'removal-proposed-on-ship',
|
|
238
|
+
// ENGINEER, not artist: confirming asks whether deleted code is genuinely
|
|
239
|
+
// gone or was moved, which is a question about the diff.
|
|
240
|
+
discipline: 'engineer',
|
|
241
|
+
kind: 'unclassified',
|
|
242
|
+
// The anti-recursion guard FIRST, the same order and for the same reason as
|
|
243
|
+
// the rule above — a cascade-generated task must never cascade.
|
|
244
|
+
//
|
|
245
|
+
// The candidates are resolved ONCE into ctx by runCascadesForShip, never
|
|
246
|
+
// here: a `when` is pure and synchronous by contract, and this derivation is
|
|
247
|
+
// a network read. An absent ctx therefore means "nothing was derived" and
|
|
248
|
+
// files nothing — the opposite default to the artist gate above, and
|
|
249
|
+
// deliberately so: silence about a removal must never be read as a removal.
|
|
250
|
+
when: (t, ctx) => t.source !== CASCADE_SOURCE
|
|
251
|
+
&& Array.isArray((ctx || {}).removals) && (ctx || {}).removals.length > 0,
|
|
252
|
+
title: (t) => `Was this removed? Task ${t.id} deleted earlier shipped work`,
|
|
253
|
+
body: removalProposalBody,
|
|
254
|
+
}),
|
|
192
255
|
]);
|
|
193
256
|
|
|
194
257
|
// THE TRIGGERS. A cascade reacts to a state transition; there are two, and this
|
|
@@ -319,7 +382,7 @@ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship)
|
|
|
319
382
|
}
|
|
320
383
|
if (!hit) continue;
|
|
321
384
|
try {
|
|
322
|
-
matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger) });
|
|
385
|
+
matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger, ctx) });
|
|
323
386
|
} catch (e) {
|
|
324
387
|
errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
|
|
325
388
|
}
|
|
@@ -329,8 +392,11 @@ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship)
|
|
|
329
392
|
|
|
330
393
|
// The createTask payload for one matched rule. The attribution preamble is
|
|
331
394
|
// prepended here rather than left to each rule, so no rule can forget it.
|
|
332
|
-
|
|
333
|
-
|
|
395
|
+
// `ctx` reaches body() as a second argument, mirroring when(). The artist rule
|
|
396
|
+
// ignores it; the removal rule needs it, because what a ship deleted is derived
|
|
397
|
+
// per run and is not a property of the task row.
|
|
398
|
+
function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship, ctx = {}) {
|
|
399
|
+
const body = String(rule.body(task, ctx) || '').trim();
|
|
334
400
|
const preamble =
|
|
335
401
|
`Filed automatically when ${trigger.happened(task)} — the "${rule.key}" cascade `
|
|
336
402
|
+ `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
|
|
@@ -466,6 +532,27 @@ async function runCascadesForShip(shipped, deps = {}) {
|
|
|
466
532
|
try { ctx.artistGate = deps.artistGateMode(); }
|
|
467
533
|
catch (e) { report.errors.push(`cascade: artistGateMode() threw (${e && e.message}) — policy unread`); }
|
|
468
534
|
}
|
|
535
|
+
// WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). Resolved here
|
|
536
|
+
// rather than in the rule for the reason the rule's own comment gives: a
|
|
537
|
+
// `when` is pure and synchronous, and this is a network read.
|
|
538
|
+
//
|
|
539
|
+
// Same posture as the policy read above — a resolver that throws is REPORTED
|
|
540
|
+
// and leaves the keys unset, so a derivation outage files no proposal and
|
|
541
|
+
// costs the ship nothing. `removalCandidates` already swallows its own
|
|
542
|
+
// failures (removal-derivation.js returns a report rather than rejecting);
|
|
543
|
+
// this try/catch is the belt to that braces, because a non-blocking promise
|
|
544
|
+
// is only non-blocking until someone edits it.
|
|
545
|
+
if (typeof deps.removalCandidates === 'function') {
|
|
546
|
+
try {
|
|
547
|
+
const derived = await deps.removalCandidates(task);
|
|
548
|
+
ctx.removals = (derived && derived.removals) || [];
|
|
549
|
+
ctx.removalTruncated = Boolean(derived && derived.truncated);
|
|
550
|
+
ctx.removalDeletedCount = (derived && derived.deletedCount) || 0;
|
|
551
|
+
for (const e of (derived && derived.errors) || []) report.errors.push(e);
|
|
552
|
+
} catch (e) {
|
|
553
|
+
report.errors.push(`cascade: removalCandidates() threw (${e && e.message}) — no removal derived`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
469
556
|
|
|
470
557
|
const { matched, errors } = cascadesFor(task, deps.rules || CASCADES, ctx);
|
|
471
558
|
report.errors.push(...errors);
|
|
@@ -480,6 +567,7 @@ async function runCascadesForShip(shipped, deps = {}) {
|
|
|
480
567
|
|
|
481
568
|
module.exports = {
|
|
482
569
|
CASCADES,
|
|
570
|
+
removalProposalBody,
|
|
483
571
|
IDEA_CASCADES,
|
|
484
572
|
TRIGGERS,
|
|
485
573
|
fullIdeaScopingBody,
|
|
@@ -761,6 +761,48 @@ async function getClaimPublishedSha(taskId) {
|
|
|
761
761
|
return rows[0]?.published_head_sha ?? null;
|
|
762
762
|
}
|
|
763
763
|
|
|
764
|
+
// shippedTasksByPublishedSha — the reverse of getClaimPublishedSha: given commit
|
|
765
|
+
// shas, which SHIPPED tasks published them (BV2.R15, ADR 0313).
|
|
766
|
+
//
|
|
767
|
+
// This is how removal attribution answers "whose work was deleted". ADR 0313
|
|
768
|
+
// rejected the obvious alternative — parsing `(task NNNN)` out of commit messages —
|
|
769
|
+
// because only 7 of the last 15 commits on main carry a task id: merge commits and
|
|
770
|
+
// release carriers do not, and a convention that holds most of the time is not a
|
|
771
|
+
// basis for something that moves credits. `published_head_sha` is written by the
|
|
772
|
+
// SERVER at publish time and cannot be forgotten by an author.
|
|
773
|
+
//
|
|
774
|
+
// Only `shipped` rows come back, deliberately. Work that never landed cannot have
|
|
775
|
+
// been removed, and a proposal naming an abandoned task would be noise a person
|
|
776
|
+
// has to read and dismiss.
|
|
777
|
+
//
|
|
778
|
+
// INDEXED by core_252 (claims_published_head_sha_idx, partial on NOT NULL). This
|
|
779
|
+
// is the first reader to go BACKWARD along that column — every prior one reads
|
|
780
|
+
// forward by task_id — so before that index it planned a full scan of the
|
|
781
|
+
// claims table on the ship path, on a table that grows one row per claim
|
|
782
|
+
// forever.
|
|
783
|
+
//
|
|
784
|
+
// Returns a Map of sha -> { taskId, title }. Shas with no claim are simply absent:
|
|
785
|
+
// `published_head_sha` is nullable (rows predating migrations/171, or a publish
|
|
786
|
+
// that rode another branch), and ADR 0313 is explicit that an unattributable
|
|
787
|
+
// deletion files NOTHING. Silence is the correct output — a wrong removal proposal
|
|
788
|
+
// costs a person attention and dims a real star.
|
|
789
|
+
async function shippedTasksByPublishedSha(shas) {
|
|
790
|
+
const list = [...new Set((Array.isArray(shas) ? shas : [])
|
|
791
|
+
.filter((s) => typeof s === 'string' && /^[0-9a-f]{7,40}$/i.test(s)))];
|
|
792
|
+
if (!list.length) return new Map();
|
|
793
|
+
const { rows } = await pool.query(
|
|
794
|
+
`SELECT c.published_head_sha AS sha, t.id AS task_id, t.title
|
|
795
|
+
FROM claims c
|
|
796
|
+
JOIN tasks t ON t.id = c.task_id
|
|
797
|
+
WHERE c.published_head_sha = ANY($1::text[])
|
|
798
|
+
AND t.status = 'shipped'`,
|
|
799
|
+
[list]
|
|
800
|
+
);
|
|
801
|
+
const out = new Map();
|
|
802
|
+
for (const r of rows) out.set(r.sha, { taskId: String(r.task_id), title: r.title });
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
764
806
|
// V3.R14 (#238) — onboarding observability roster.
|
|
765
807
|
//
|
|
766
808
|
// One row per builder, joined with the live "what are they doing right
|
|
@@ -1408,6 +1450,7 @@ module.exports = {
|
|
|
1408
1450
|
getActiveClaimsForBuilderWithStale,
|
|
1409
1451
|
getBuildersRoster,
|
|
1410
1452
|
getClaimPublishedSha,
|
|
1453
|
+
shippedTasksByPublishedSha,
|
|
1411
1454
|
listConfirmedTasksForReconcile,
|
|
1412
1455
|
needsRebaseTasksForBuilder,
|
|
1413
1456
|
gatingRebaseTasksForBuilder,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -185,6 +185,7 @@ const {
|
|
|
185
185
|
getActiveClaimsForBuilderWithStale,
|
|
186
186
|
getBuildersRoster,
|
|
187
187
|
getClaimPublishedSha,
|
|
188
|
+
shippedTasksByPublishedSha,
|
|
188
189
|
listConfirmedTasksForReconcile,
|
|
189
190
|
mergeTask,
|
|
190
191
|
needsRebaseTasksForBuilder,
|
|
@@ -330,6 +331,7 @@ module.exports = {
|
|
|
330
331
|
listConfirmedTasksForReconcile, // task 1134 — server-owned publish reconciler feed
|
|
331
332
|
setClaimPublishedSha, // task 1547 — record the SERVER-pushed tip for per-task land proof
|
|
332
333
|
getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
|
|
334
|
+
shippedTasksByPublishedSha, // task 1004090 / BV2.R15 — the reverse read, for removal attribution
|
|
333
335
|
getActiveClaimsForBuilder,
|
|
334
336
|
moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
|
|
335
337
|
activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/removal-derivation.js — WHAT A SHIP DELETED, AND WHOSE WORK
|
|
4
|
+
// IT WAS (task 1004090 / BV2.R15, goal 1000095; ADR 0313).
|
|
5
|
+
//
|
|
6
|
+
// Brightness (R20) is the live share of a descent, so something has to be able to
|
|
7
|
+
// say that shipped work was later removed. Nothing recorded that: `shipped` is
|
|
8
|
+
// terminal, there is no reverted status, and `tasks.merged_into_task_id` is
|
|
9
|
+
// CHECK-constrained to abandoned rows. This file derives the signal.
|
|
10
|
+
//
|
|
11
|
+
// It produces a QUESTION, never a verdict. The rule in ./cascade.js files a task
|
|
12
|
+
// at backlog; a person confirms it (R16). Nothing here changes a status, and
|
|
13
|
+
// nothing here touches credits — ADR 0313 is explicit that the ledger is never
|
|
14
|
+
// rewritten, because refactors, renames and file moves all look like deletions to
|
|
15
|
+
// a diff and a derived-only signal would dim the sky continuously.
|
|
16
|
+
//
|
|
17
|
+
// ── WHY NOT THE SIGNAL THAT ALREADY EXISTS ──────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// `netNegativeBonusAmount` is the nearest existing thing and is unusable, for
|
|
20
|
+
// three reasons ADR 0313 sets out:
|
|
21
|
+
// • It has no TARGET. `removed > added` over a whole ship says that deletion
|
|
22
|
+
// happened, never what was deleted or whose work it was. Brightness needs the
|
|
23
|
+
// target; the boolean carries none.
|
|
24
|
+
// • It is CLIENT-SUPPLIED — it reads `signals.diff_stats` from the grade request
|
|
25
|
+
// body, and `grader-score.js` already documents defending against a forged
|
|
26
|
+
// `diff_stats`.
|
|
27
|
+
// • Brightness sits beside money. Deriving it from a forgeable, target-less
|
|
28
|
+
// counter would be wrong even where it was convenient.
|
|
29
|
+
//
|
|
30
|
+
// So the signal comes from the SERVER's own view of the merge instead. Landing is
|
|
31
|
+
// server-mediated (ADR 0055): the server holds the authority and can ask GitHub
|
|
32
|
+
// what the pull request actually changed, rather than be told by the box that
|
|
33
|
+
// built it.
|
|
34
|
+
|
|
35
|
+
const api = require('../../src/module-api');
|
|
36
|
+
const githubPush = require('./github-push');
|
|
37
|
+
|
|
38
|
+
// How many deleted paths one ship will be attributed — and therefore the HARD
|
|
39
|
+
// CEILING on GitHub calls this derivation can make per ship. Each path costs one
|
|
40
|
+
// commit-history request, and they run on the server's shared push credential,
|
|
41
|
+
// which the publish path itself depends on: spending that budget to answer a
|
|
42
|
+
// question nobody asked for would be a poor trade.
|
|
43
|
+
//
|
|
44
|
+
// A ship that removes more than this is a sweep — a directory move, a vendored
|
|
45
|
+
// drop, a module deletion — where the proposal is least useful and the cost is
|
|
46
|
+
// highest. Eight is deliberately small: a genuine "this ship removed someone's
|
|
47
|
+
// feature" is a handful of files, and beyond that the truncation note tells a
|
|
48
|
+
// reader to weigh the ship as a move rather than a removal.
|
|
49
|
+
//
|
|
50
|
+
// The cap is on the ATTRIBUTION, not the detection: the deletion count is still
|
|
51
|
+
// reported honestly so the proposal can say its list is partial.
|
|
52
|
+
const MAX_ATTRIBUTED_PATHS = 8;
|
|
53
|
+
|
|
54
|
+
// listCommitsForPath — the shas of the commits that touched ONE path, newest
|
|
55
|
+
// first. The read behind attribution: a deleted file is blamed back to the commit
|
|
56
|
+
// that introduced it, and that sha is resolved through `claims.published_head_sha`.
|
|
57
|
+
//
|
|
58
|
+
// IT LIVES HERE RATHER THAN IN github-push.js, which is where its siblings live,
|
|
59
|
+
// because that file sits ON the 1500-line ratchet ceiling — the same reason
|
|
60
|
+
// `pr-assign.js` was carved out of it, and this module's CLAUDE.md says so in as
|
|
61
|
+
// many words. Its only consumer is the derivation below.
|
|
62
|
+
//
|
|
63
|
+
// WHY THE API AND NOT `git log`. The server keeps no persistent clone — pushBundle
|
|
64
|
+
// works in a temp dir off a bundle and throws it away — so there is no working tree
|
|
65
|
+
// to blame in. GitHub holds the history. Same reason getPullRequestFiles reads the
|
|
66
|
+
// REST files API rather than diffing locally.
|
|
67
|
+
//
|
|
68
|
+
// `until` bounds the walk to commits at or before the merge being examined, so a
|
|
69
|
+
// LATER commit that re-added the path cannot be mistaken for the one that
|
|
70
|
+
// introduced it.
|
|
71
|
+
//
|
|
72
|
+
// Caps at ONE page of 100. Attribution only needs to find a published tip, and a
|
|
73
|
+
// path with more than 100 commits above the one that introduced it is old enough
|
|
74
|
+
// that the introducing claim almost certainly predates `published_head_sha`
|
|
75
|
+
// (migrations/171) — in which case the correct output is no attribution at all,
|
|
76
|
+
// which is exactly what an exhausted page yields. Paging further would spend rate
|
|
77
|
+
// limit to reach the same silence.
|
|
78
|
+
async function listCommitsForPath({ path: filePath, until = null }, deps = {}) {
|
|
79
|
+
// resolveToken is injectable for the same reason fetchImpl and repoInfo are:
|
|
80
|
+
// this function is the one place a rate-limit response is classified, and that
|
|
81
|
+
// classification has to be testable without a live credential.
|
|
82
|
+
const token = deps.resolveToken ? await deps.resolveToken(deps) : await githubPush.resolveToken(deps);
|
|
83
|
+
if (!token) throw new Error('PUSH_UNCONFIGURED');
|
|
84
|
+
if (typeof filePath !== 'string' || !filePath) throw new Error('BAD_PATH');
|
|
85
|
+
const info = deps.repoInfo || api.repoInfo;
|
|
86
|
+
if (!info || info.error || !info.owner || !info.repo) throw new Error('NO_REPO_INFO');
|
|
87
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
88
|
+
const qs = new URLSearchParams({ path: filePath, per_page: '100' });
|
|
89
|
+
if (until) qs.set('until', String(until));
|
|
90
|
+
const res = await doFetch(
|
|
91
|
+
`https://api.github.com/repos/${info.owner}/${info.repo}/commits?${qs.toString()}`,
|
|
92
|
+
{ headers: githubPush.ghHeaders(token) }
|
|
93
|
+
);
|
|
94
|
+
// A path that has never existed is a 404 and an EMPTY history, not an error: the
|
|
95
|
+
// caller asks "who introduced this", and "nobody we can see" is an answer.
|
|
96
|
+
if (res.status === 404) return [];
|
|
97
|
+
// RATE LIMITING IS NOT A PER-PATH FAILURE — it is a statement about the whole
|
|
98
|
+
// credential, so it must not be retried and must not be walked past. GitHub
|
|
99
|
+
// signals it as 403 or 429 with the remaining count at zero (a 403 for any
|
|
100
|
+
// other reason keeps a non-zero remaining, or sends no header at all, and is
|
|
101
|
+
// treated as an ordinary failure). Thrown with a recognisable name so the
|
|
102
|
+
// caller can ABANDON the rest of the walk rather than spend the next seven
|
|
103
|
+
// requests learning the same thing.
|
|
104
|
+
if (res.status === 429 || res.status === 403) {
|
|
105
|
+
const remaining = res.headers && typeof res.headers.get === 'function'
|
|
106
|
+
? res.headers.get('x-ratelimit-remaining')
|
|
107
|
+
: null;
|
|
108
|
+
if (res.status === 429 || remaining === '0') {
|
|
109
|
+
const e = new Error('RATE_LIMITED');
|
|
110
|
+
e.rateLimited = true;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok) throw new Error(`STATUS_FETCH_FAILED ${res.status}`);
|
|
115
|
+
const commits = await res.json();
|
|
116
|
+
if (!Array.isArray(commits)) return [];
|
|
117
|
+
return commits.map((c) => c && c.sha).filter((sha) => typeof sha === 'string' && sha);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// The pure half
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
// WHAT COUNTS AS REMOVED. ADR 0313 names three exclusions, and each is a status
|
|
125
|
+
// the REST files API already distinguishes — which is exactly why the derivation
|
|
126
|
+
// reads that API rather than counting lines:
|
|
127
|
+
//
|
|
128
|
+
// • 'renamed' — a rename or move is NOT a removal. Git's own rename detection
|
|
129
|
+
// resolves it and the work still stands. This is the exclusion
|
|
130
|
+
// that matters most: a directory reorganisation would otherwise
|
|
131
|
+
// propose a removal for every file in it.
|
|
132
|
+
// • 'modified' — a rewrite in place is NOT a removal. Lines changing is how
|
|
133
|
+
// code lives, and this is where a line-count heuristic
|
|
134
|
+
// (`removed > added`) goes wrong.
|
|
135
|
+
// • 'added' / 'copied' / 'changed' — nothing was lost.
|
|
136
|
+
//
|
|
137
|
+
// Only 'removed' survives. The third ADR exclusion — a cold body is not a remnant
|
|
138
|
+
// — is not expressible here: it is enforced downstream by attributing solely to
|
|
139
|
+
// tasks that actually SHIPPED (see shippedTasksByPublishedSha), because an idea
|
|
140
|
+
// declined at triage never ignited and nothing was built to lose.
|
|
141
|
+
//
|
|
142
|
+
// Pure: hand it the files array and it answers. No network, no clock, no DB.
|
|
143
|
+
function deletedPathsFrom(files) {
|
|
144
|
+
if (!Array.isArray(files)) return [];
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
if (!f || typeof f !== 'object') continue;
|
|
148
|
+
// `deleted` is getPullRequestFiles' own mapping of status === 'removed'.
|
|
149
|
+
// Both are read, and they must AGREE: trusting `deleted` alone would follow a
|
|
150
|
+
// caller that set it by hand, and trusting `status` alone would miss a caller
|
|
151
|
+
// that shaped the row the other way. Where only one is present, it decides.
|
|
152
|
+
const status = typeof f.status === 'string' ? f.status.toLowerCase() : null;
|
|
153
|
+
const flagged = f.deleted === true;
|
|
154
|
+
const isRemoval = status ? status === 'removed' : flagged;
|
|
155
|
+
if (!isRemoval) continue;
|
|
156
|
+
if (typeof f.path !== 'string' || !f.path) continue;
|
|
157
|
+
out.push(f.path);
|
|
158
|
+
}
|
|
159
|
+
return [...new Set(out)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Group attributed paths by the task that published them, so the proposal reads
|
|
163
|
+
// as "this task lost these files" rather than a flat list a person has to sort.
|
|
164
|
+
// Pure. Insertion order is preserved, which keeps the rendered body stable for a
|
|
165
|
+
// given diff rather than reshuffling between runs.
|
|
166
|
+
function groupByTask(attributions) {
|
|
167
|
+
const byTask = new Map();
|
|
168
|
+
for (const a of Array.isArray(attributions) ? attributions : []) {
|
|
169
|
+
if (!a || a.taskId == null) continue;
|
|
170
|
+
const key = String(a.taskId);
|
|
171
|
+
if (!byTask.has(key)) byTask.set(key, { taskId: key, title: a.title || null, paths: [] });
|
|
172
|
+
const row = byTask.get(key);
|
|
173
|
+
if (a.path && !row.paths.includes(a.path)) row.paths.push(a.path);
|
|
174
|
+
}
|
|
175
|
+
return [...byTask.values()];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// The impure half — one network read per deleted path, then one DB read
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
// Resolve deleted paths to the shipped tasks that published them.
|
|
183
|
+
//
|
|
184
|
+
// For each path: ask GitHub which commits touched it up to the merge, then look
|
|
185
|
+
// those shas up against `claims.published_head_sha`. The OLDEST matching commit is
|
|
186
|
+
// taken as the one that introduced the file — the newest would attribute a file to
|
|
187
|
+
// whoever last edited it, which is a different and wrong question.
|
|
188
|
+
//
|
|
189
|
+
// `shipTaskId` is excluded: a ship that deletes its OWN earlier file is a builder
|
|
190
|
+
// tidying up inside one piece of work, not a removal of someone else's.
|
|
191
|
+
//
|
|
192
|
+
// Everything is best-effort per path. One path whose history cannot be read costs
|
|
193
|
+
// that path its attribution and nothing else — the other paths still resolve, and
|
|
194
|
+
// a total failure yields an empty list, which files no proposal at all.
|
|
195
|
+
async function attributeDeletions({ paths, shipTaskId, until = null }, deps = {}) {
|
|
196
|
+
const listCommits = deps.listCommitsForPath || listCommitsForPath;
|
|
197
|
+
const lookup = deps.shippedTasksByPublishedSha;
|
|
198
|
+
const errors = [];
|
|
199
|
+
if (typeof lookup !== 'function') return { attributions: [], errors: ['no sha lookup provided'] };
|
|
200
|
+
|
|
201
|
+
const wanted = (Array.isArray(paths) ? paths : []).slice(0, MAX_ATTRIBUTED_PATHS);
|
|
202
|
+
const perPath = [];
|
|
203
|
+
for (const path of wanted) {
|
|
204
|
+
try {
|
|
205
|
+
// Newest first from the API; reversed so the OLDEST visible commit wins.
|
|
206
|
+
const shas = await listCommits({ path, until });
|
|
207
|
+
perPath.push({ path, shas: [...(Array.isArray(shas) ? shas : [])].reverse() });
|
|
208
|
+
} catch (e) {
|
|
209
|
+
// A rate-limit refusal ends the WALK, not just this path. Continuing would
|
|
210
|
+
// spend the remaining budget on requests already known to fail, on the same
|
|
211
|
+
// credential the publish path needs — the opposite of backing off. There is
|
|
212
|
+
// deliberately no retry and no sleep: this runs on the ship path, and
|
|
213
|
+
// waiting there to dim a star later is the wrong trade (ADR 0313).
|
|
214
|
+
if (e && e.rateLimited) {
|
|
215
|
+
errors.push('removal: GitHub rate limit reached — attribution abandoned for this ship');
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
errors.push(`removal: history for ${path} unreadable (${e && e.message})`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ONE database read for every path's shas together, rather than one per path.
|
|
223
|
+
let shaToTask;
|
|
224
|
+
try {
|
|
225
|
+
shaToTask = await lookup(perPath.flatMap((p) => p.shas));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { attributions: [], errors: [...errors, `removal: sha lookup failed (${e && e.message})`] };
|
|
228
|
+
}
|
|
229
|
+
if (!(shaToTask instanceof Map)) return { attributions: [], errors: [...errors, 'removal: sha lookup returned no map'] };
|
|
230
|
+
|
|
231
|
+
const attributions = [];
|
|
232
|
+
for (const { path, shas } of perPath) {
|
|
233
|
+
for (const sha of shas) {
|
|
234
|
+
const hit = shaToTask.get(sha);
|
|
235
|
+
if (!hit) continue;
|
|
236
|
+
if (shipTaskId != null && String(hit.taskId) === String(shipTaskId)) break;
|
|
237
|
+
attributions.push({ path, taskId: hit.taskId, title: hit.title, sha });
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { attributions, errors };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// THE WHOLE DERIVATION, as the one call the cascade context resolver makes.
|
|
245
|
+
//
|
|
246
|
+
// NON-BLOCKING IS THE CONTRACT, not a nicety. This runs on the path a ship
|
|
247
|
+
// already takes, and ADR 0313 says so in as many words: "a derivation that errors
|
|
248
|
+
// files nothing and never fails the merge. Landing work is more important than
|
|
249
|
+
// dimming a star on time." So every failure mode below returns a report rather
|
|
250
|
+
// than throwing, and the caller treats an empty `removals` as "file nothing".
|
|
251
|
+
async function deriveRemovalsForShip({ taskId, prNumber, until = null }, deps = {}) {
|
|
252
|
+
const empty = (errors) => ({ removals: [], deletedCount: 0, truncated: false, errors });
|
|
253
|
+
try {
|
|
254
|
+
if (prNumber == null) return empty(['removal: no pull request for this ship — nothing to read']);
|
|
255
|
+
|
|
256
|
+
const getFiles = deps.getPullRequestFiles
|
|
257
|
+
|| ((args) => githubPush.getPullRequestFiles(args));
|
|
258
|
+
|
|
259
|
+
let files;
|
|
260
|
+
try {
|
|
261
|
+
files = await getFiles({ number: Number(prNumber) });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return empty([`removal: PR files unreadable (${e && e.message})`]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const deleted = deletedPathsFrom(files);
|
|
267
|
+
if (!deleted.length) return empty([]);
|
|
268
|
+
|
|
269
|
+
const { attributions, errors } = await attributeDeletions(
|
|
270
|
+
{ paths: deleted, shipTaskId: taskId, until },
|
|
271
|
+
deps
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
removals: groupByTask(attributions),
|
|
276
|
+
deletedCount: deleted.length,
|
|
277
|
+
truncated: deleted.length > MAX_ATTRIBUTED_PATHS,
|
|
278
|
+
errors,
|
|
279
|
+
};
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return empty([`removal: ${e && e.message}`]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// removalCandidatesForTask — the whole resolver the ship listener wires, as ONE
|
|
286
|
+
// testable function rather than a closure inside the listener.
|
|
287
|
+
//
|
|
288
|
+
// IT LIVES HERE BECAUSE THE CLOSURE VERSION SHIPPED A DEAD FEATURE. Written
|
|
289
|
+
// inline in routes/lifecycle.js it read `pr.number` from findTaskPullRequest,
|
|
290
|
+
// which returns `pr_number` — so `prNumber` was null on every real ship,
|
|
291
|
+
// deriveRemovalsForShip short-circuited every time, and the rule could never
|
|
292
|
+
// fire in production. Every test injected `removalCandidates` or
|
|
293
|
+
// `getPullRequestFiles` directly, so the whole suite passed over a feature that
|
|
294
|
+
// did nothing: the parts agreed with each other and none of them was wired to
|
|
295
|
+
// the part that was wrong.
|
|
296
|
+
//
|
|
297
|
+
// A closure inside a listener registration is not reachable from a test without
|
|
298
|
+
// rebuilding the router and intercepting the registration. Naming it makes the
|
|
299
|
+
// field read testable, and leaves the listener a single line that cannot hide a
|
|
300
|
+
// second one.
|
|
301
|
+
//
|
|
302
|
+
// Resolving the PR here rather than inside deriveRemovalsForShip keeps THAT
|
|
303
|
+
// function free of the task->PR question, which github-push already answers.
|
|
304
|
+
// A task with no findable PR derives nothing, which files nothing.
|
|
305
|
+
async function removalCandidatesForTask(task, deps = {}) {
|
|
306
|
+
const empty = { removals: [], deletedCount: 0, truncated: false, errors: [] };
|
|
307
|
+
const findPr = deps.findTaskPullRequest
|
|
308
|
+
|| ((args) => githubPush.findTaskPullRequest(args));
|
|
309
|
+
let prNumber = null;
|
|
310
|
+
try {
|
|
311
|
+
const pr = await findPr({ taskId: task && task.id });
|
|
312
|
+
// `pr_number`, NOT `pr.number` — findTaskPullRequest returns
|
|
313
|
+
// { branch, pr_number, pr_state, merged }. Pinned by test, because reading
|
|
314
|
+
// the wrong field here is silent: it yields null, which is a legal "no PR"
|
|
315
|
+
// answer, so the feature simply never runs and nothing ever complains.
|
|
316
|
+
prNumber = pr && pr.pr_number != null ? pr.pr_number : null;
|
|
317
|
+
} catch (_) {
|
|
318
|
+
// Never let the PR lookup cost the ship. Same contract as everything below.
|
|
319
|
+
return empty;
|
|
320
|
+
}
|
|
321
|
+
return deriveRemovalsForShip({ taskId: task && task.id, prNumber }, deps);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
MAX_ATTRIBUTED_PATHS,
|
|
326
|
+
removalCandidatesForTask,
|
|
327
|
+
listCommitsForPath,
|
|
328
|
+
deletedPathsFrom,
|
|
329
|
+
groupByTask,
|
|
330
|
+
attributeDeletions,
|
|
331
|
+
deriveRemovalsForShip,
|
|
332
|
+
};
|
|
@@ -28,6 +28,7 @@ const api = require('../../../src/module-api');
|
|
|
28
28
|
const lifecycle = require('../lifecycle');
|
|
29
29
|
const db = require('../db');
|
|
30
30
|
const cascade = require('../cascade');
|
|
31
|
+
const removalDerivation = require('../removal-derivation');
|
|
31
32
|
|
|
32
33
|
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
33
34
|
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
@@ -78,6 +79,29 @@ function registerSeams() {
|
|
|
78
79
|
// HOLDS a deploy can never be read differently. At 'off' the artist-review
|
|
79
80
|
// rule files nothing.
|
|
80
81
|
artistGateMode: () => api.artistGate.artistGateMode(),
|
|
82
|
+
// WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). The derivation
|
|
83
|
+
// asks GitHub what the pull request actually changed and blames the deleted
|
|
84
|
+
// paths back through `claims.published_head_sha` — the SERVER's own view of
|
|
85
|
+
// the merge, never the client-supplied `diff_stats` the net-negative bonus
|
|
86
|
+
// reads, which is target-less and already known-forgeable.
|
|
87
|
+
//
|
|
88
|
+
// Resolving the PR number here rather than inside the derivation keeps that
|
|
89
|
+
// file free of the task->PR question, which github-push already answers.
|
|
90
|
+
// A task with no findable PR derives nothing, which files nothing.
|
|
91
|
+
//
|
|
92
|
+
// Every failure is swallowed into an empty result: this runs on the ship
|
|
93
|
+
// path, and ADR 0313 is explicit that landing work matters more than dimming
|
|
94
|
+
// a star on time.
|
|
95
|
+
//
|
|
96
|
+
// The resolver itself is `removalCandidatesForTask`, NOT a closure here. It
|
|
97
|
+
// was a closure, and it read the wrong field off findTaskPullRequest
|
|
98
|
+
// (`number` for `pr_number`), which made the whole rule dead in production
|
|
99
|
+
// while every test passed — because every test injected this dep. A closure
|
|
100
|
+
// inside a listener registration is not reachable from a test; a named
|
|
101
|
+
// function is, and this line is now too small to hide a bug of its own.
|
|
102
|
+
removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
|
|
103
|
+
shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
|
|
104
|
+
}),
|
|
81
105
|
log: api.logger ? api.logger('lifecycle') : null,
|
|
82
106
|
}));
|
|
83
107
|
}
|
|
@@ -194,15 +194,32 @@ const shippedCopy = (over = {}) => ({
|
|
|
194
194
|
...over,
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
-
await test('the artist review is
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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');
|
|
201
208
|
assert.equal(r.discipline, 'artist', 'the review is FOR the artist, whatever role shipped the work');
|
|
202
209
|
assert.equal(r.kind, 'unclassified', 'a valid kind — not the invalid "chore" the spec used to default to');
|
|
203
210
|
assert.deepEqual(cascade.validateCascades(), [], 'and the shipped table is well-formed');
|
|
204
211
|
});
|
|
205
212
|
|
|
213
|
+
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
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
206
223
|
await test('a copy/visual ship generates the two-step artist review', () => {
|
|
207
224
|
const { matched } = cascade.cascadesFor(shippedCopy());
|
|
208
225
|
assert.equal(matched.length, 1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.918",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.918",
|
|
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.
|
|
3
|
+
"version": "1.19.918",
|
|
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",
|