@bongos/core 1.19.808 → 1.19.810
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 +25 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/abandon-leftovers.js +103 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/abandon.js +48 -0
- package/scripts/gds/ship-flow.js +27 -17
- package/src/bongos/module-scope-map.js +58 -1
- package/src/module-api.js +1 -1
- package/tests/abandon_leftovers.mjs +87 -0
- package/tests/module-scope-map.mjs +96 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.810",
|
|
6
|
+
"core_contract": "1.19.810",
|
|
7
|
+
"source_commit": "a3d5dcdde5918a6ce46742502351896701a5a312",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-17T06:04:48.341Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 487,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2198,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2710,
|
|
20
|
+
"tree_sha256": "43e8bede11fa1d3c211405b457b91c73433af719f13cf34869cd9894607688b7",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2532,7 +2532,7 @@
|
|
|
2532
2532
|
{
|
|
2533
2533
|
"path": "docs/module-api-changelog.md",
|
|
2534
2534
|
"mode": "0000644",
|
|
2535
|
-
"sha256": "
|
|
2535
|
+
"sha256": "559a5a32d7f2bbdfc6059b033898244d10bba1abc6881cc9d42388d135c1a164"
|
|
2536
2536
|
},
|
|
2537
2537
|
{
|
|
2538
2538
|
"path": "docs/modules-contract.md",
|
|
@@ -5459,6 +5459,11 @@
|
|
|
5459
5459
|
"mode": "0000644",
|
|
5460
5460
|
"sha256": "7cb9dac9cb87b679e1ae50e4112b3a7928a38ebb9902c8947a74d17bec4d9009"
|
|
5461
5461
|
},
|
|
5462
|
+
{
|
|
5463
|
+
"path": "modules/lifecycle/abandon-leftovers.js",
|
|
5464
|
+
"mode": "0000644",
|
|
5465
|
+
"sha256": "a9d762f64176c26b3d3058a9e769f966b838fbb8b0dabeceeeabd8ce324ca471"
|
|
5466
|
+
},
|
|
5462
5467
|
{
|
|
5463
5468
|
"path": "modules/lifecycle/activity.js",
|
|
5464
5469
|
"mode": "0000644",
|
|
@@ -7722,12 +7727,12 @@
|
|
|
7722
7727
|
{
|
|
7723
7728
|
"path": "package-lock.json",
|
|
7724
7729
|
"mode": "0000644",
|
|
7725
|
-
"sha256": "
|
|
7730
|
+
"sha256": "85db67c033657807d558c218faa46b3a94719479ca312e38fee928ea8e845d2a"
|
|
7726
7731
|
},
|
|
7727
7732
|
{
|
|
7728
7733
|
"path": "package.json",
|
|
7729
7734
|
"mode": "0000644",
|
|
7730
|
-
"sha256": "
|
|
7735
|
+
"sha256": "1ea12b0f66807661d162a59b47b20a2e166b5d356f88a766240c8a573cf95859"
|
|
7731
7736
|
},
|
|
7732
7737
|
{
|
|
7733
7738
|
"path": "public-docs/index.html",
|
|
@@ -7777,7 +7782,7 @@
|
|
|
7777
7782
|
{
|
|
7778
7783
|
"path": "scripts/gds/abandon.js",
|
|
7779
7784
|
"mode": "0000644",
|
|
7780
|
-
"sha256": "
|
|
7785
|
+
"sha256": "723aea2c66f8897822507a7b3c7556170336ec021676b3cb8898e5c3bc4e98ca"
|
|
7781
7786
|
},
|
|
7782
7787
|
{
|
|
7783
7788
|
"path": "scripts/gds/accessibility-replay.js",
|
|
@@ -8972,7 +8977,7 @@
|
|
|
8972
8977
|
{
|
|
8973
8978
|
"path": "scripts/gds/ship-flow.js",
|
|
8974
8979
|
"mode": "0000644",
|
|
8975
|
-
"sha256": "
|
|
8980
|
+
"sha256": "bb39ac6572410567ce0a2881f26b0512df817fd04cec9130792ca24c48e8d02a"
|
|
8976
8981
|
},
|
|
8977
8982
|
{
|
|
8978
8983
|
"path": "scripts/gds/ship-git.js",
|
|
@@ -9427,7 +9432,7 @@
|
|
|
9427
9432
|
{
|
|
9428
9433
|
"path": "src/bongos/module-scope-map.js",
|
|
9429
9434
|
"mode": "0000644",
|
|
9430
|
-
"sha256": "
|
|
9435
|
+
"sha256": "8d7b749edca694b4c9d8a563a4334c7fe3fc7be64c520c3d14c3b63febff132a"
|
|
9431
9436
|
},
|
|
9432
9437
|
{
|
|
9433
9438
|
"path": "src/bongos/module-submissions.js",
|
|
@@ -9617,7 +9622,7 @@
|
|
|
9617
9622
|
{
|
|
9618
9623
|
"path": "src/module-api.js",
|
|
9619
9624
|
"mode": "0000644",
|
|
9620
|
-
"sha256": "
|
|
9625
|
+
"sha256": "a73a30f63258ab08abe959da6d8f31f8428c30c5d49772350beecb72e9131b2f"
|
|
9621
9626
|
},
|
|
9622
9627
|
{
|
|
9623
9628
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9669,6 +9674,11 @@
|
|
|
9669
9674
|
"mode": "0000644",
|
|
9670
9675
|
"sha256": "2e46d071e5c61d302f34507215dd0a5f20a0887841ae0f7b033be865c2cb8af5"
|
|
9671
9676
|
},
|
|
9677
|
+
{
|
|
9678
|
+
"path": "tests/abandon_leftovers.mjs",
|
|
9679
|
+
"mode": "0000644",
|
|
9680
|
+
"sha256": "fe9562bd27fc127f5113de4316d1abe784874aa198d87be79caa302d526ecf17"
|
|
9681
|
+
},
|
|
9672
9682
|
{
|
|
9673
9683
|
"path": "tests/abandon_task.mjs",
|
|
9674
9684
|
"mode": "0000644",
|
|
@@ -11932,7 +11942,7 @@
|
|
|
11932
11942
|
{
|
|
11933
11943
|
"path": "tests/module-scope-map.mjs",
|
|
11934
11944
|
"mode": "0000644",
|
|
11935
|
-
"sha256": "
|
|
11945
|
+
"sha256": "e277e0579dd6c8fea459cc5a3b4adf04dda12c95e69a415eeea5cfd2de52d760"
|
|
11936
11946
|
},
|
|
11937
11947
|
{
|
|
11938
11948
|
"path": "tests/module_api.mjs",
|
|
@@ -2090,5 +2090,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2090
2090
|
landed since 1.19.806 with no explicit bump. run 35183654397. (task 1002620)
|
|
2091
2091
|
1.19.808 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2092
2092
|
landed since 1.19.807 with no explicit bump. run 35185130913. (task 1002620)
|
|
2093
|
+
1.19.809 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2094
|
+
landed since 1.19.808 with no explicit bump. run 35187322516. (task 1002620)
|
|
2095
|
+
1.19.810 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2096
|
+
landed since 1.19.809 with no explicit bump. run 35188253911. (task 1002620)
|
|
2093
2097
|
---------------------------------------------------------------------------
|
|
2094
2098
|
```
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/abandon-leftovers.js — what a retired task leaves behind
|
|
4
|
+
// (task 1003779).
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT THIS EXISTS FOR. Abandoning a task retires the ROW and nothing else.
|
|
7
|
+
// Task 1002464 was abandoned carrying two commits on `task-1002464` that are not
|
|
8
|
+
// ancestors of origin/main, and an open PR. Nothing said so — not at the moment
|
|
9
|
+
// of abandonment, not afterwards — so the branch read as live work, and a
|
|
10
|
+
// follow-up filed off its grading pass (task 1003626) sat at `ready` asking for
|
|
11
|
+
// three nits in a function that exists nowhere on main. `sequence.js --check`
|
|
12
|
+
// called it "clear to run". The cost landed on whoever claimed it.
|
|
13
|
+
//
|
|
14
|
+
// SIBLING OF dead-deps.js, deliberately. That file makes an abandoned
|
|
15
|
+
// DEPENDENCY observable; this one makes abandoned CODE observable. Same posture:
|
|
16
|
+
// pure and git-free here so the CLI is the only place that shells out, and the
|
|
17
|
+
// rule can be tested without a repository.
|
|
18
|
+
//
|
|
19
|
+
// WHAT THIS IS NOT. It does not delete a branch, close a PR, or touch the
|
|
20
|
+
// remote. Retiring a row is reversible (POST /tasks/:id/promote); deleting the
|
|
21
|
+
// only copy of unmerged work is not, and which of the two a leftover branch
|
|
22
|
+
// deserves is a human call. This reports, loudly, with the commands to hand.
|
|
23
|
+
//
|
|
24
|
+
// WHY NOT source_ref, which the report suggested. The incident that motivated
|
|
25
|
+
// this task is the counter-example: 1003626's `source_ref` is `idea:1000935`,
|
|
26
|
+
// not the branch or task it was stranded behind — the relationship lived only in
|
|
27
|
+
// the prose of its description. A source_ref sweep would have found nothing.
|
|
28
|
+
// The branch is the artifact that actually exists, so the branch is what is
|
|
29
|
+
// checked.
|
|
30
|
+
|
|
31
|
+
// A branch belongs to task N if it is `task-N` or `task-N-<slug>` (the two
|
|
32
|
+
// shapes worktree.js produces). Anchored at both ends so task-1002450 never
|
|
33
|
+
// matches task-10024501 — a prefix match here would report another task's work
|
|
34
|
+
// as this one's leftovers, which is worse than reporting nothing.
|
|
35
|
+
function branchesForTask(taskId, branches) {
|
|
36
|
+
const id = String(taskId == null ? '' : taskId).trim();
|
|
37
|
+
if (!/^\d+$/.test(id)) return [];
|
|
38
|
+
const re = new RegExp(`^task-${id}(?:-[A-Za-z0-9._-]+)?$`);
|
|
39
|
+
// Deduped after stripping the remote prefix: a caller may pass the same branch
|
|
40
|
+
// as both 'task-N' and 'origin/task-N' (git branch -r vs ls-remote), and the
|
|
41
|
+
// notice would then tell a builder to delete one branch twice.
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const raw of branches || []) {
|
|
44
|
+
const b = String(raw || '').trim().replace(/^origin\//, '');
|
|
45
|
+
if (re.test(b)) seen.add(b);
|
|
46
|
+
}
|
|
47
|
+
return [...seen];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure: the leftover report for one task. `aheadCount(branch)` answers "how many
|
|
51
|
+
// commits does this branch carry that main does not" — a number, or null when it
|
|
52
|
+
// could not be determined.
|
|
53
|
+
//
|
|
54
|
+
// null is NOT folded into 0. "I could not look" and "there is nothing there"
|
|
55
|
+
// must not render as the same sentence (the strand-watch.js rule): an
|
|
56
|
+
// undetermined branch is reported as undetermined, so a network failure cannot
|
|
57
|
+
// quietly certify a branch as clean.
|
|
58
|
+
function leftoversForTask(taskId, { branches, aheadCount }) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const branch of branchesForTask(taskId, branches)) {
|
|
61
|
+
let raw;
|
|
62
|
+
try { raw = aheadCount(branch); } catch (_) { raw = null; }
|
|
63
|
+
const n = Number(raw);
|
|
64
|
+
// Anything that is not a finite number is UNDETERMINED, not zero. The first
|
|
65
|
+
// cut of this tested only for null/undefined, so a NaN — what
|
|
66
|
+
// `Number(git rev-list output)` yields when the command prints nothing —
|
|
67
|
+
// fell through to the "> 0" test, failed it, and was dropped as clean. That
|
|
68
|
+
// is the exact failure this module's header forbids: a branch nobody could
|
|
69
|
+
// read certified as a branch with nothing on it.
|
|
70
|
+
if (raw === null || raw === undefined || !Number.isFinite(n)) {
|
|
71
|
+
out.push({ branch, ahead: null, undetermined: true });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (n > 0) out.push({ branch, ahead: n, undetermined: false });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The message a builder sees. Returns null when there is nothing to say, so the
|
|
80
|
+
// caller can stay silent on the clean path rather than printing a reassurance
|
|
81
|
+
// nobody reads (and which would train people to skip the line that matters).
|
|
82
|
+
function leftoverNotice(taskId, leftovers) {
|
|
83
|
+
if (!leftovers || !leftovers.length) return null;
|
|
84
|
+
const lines = [`\n⚠ Task #${taskId} is retired, but its work is still on the remote:`];
|
|
85
|
+
for (const l of leftovers) {
|
|
86
|
+
lines.push(l.undetermined
|
|
87
|
+
? ` - ${l.branch} — could not tell whether it carries unmerged commits`
|
|
88
|
+
: ` - ${l.branch} — ${l.ahead} commit(s) not on main`);
|
|
89
|
+
}
|
|
90
|
+
lines.push(
|
|
91
|
+
'',
|
|
92
|
+
'An abandoned task with a live branch reads as live work, and anything filed',
|
|
93
|
+
'off its review sits against code that does not exist on main. Decide which:',
|
|
94
|
+
'',
|
|
95
|
+
` git push origin --delete ${leftovers[0].branch} # the work is cut — drop it`,
|
|
96
|
+
` node scripts/gds/api.js POST /api/gds/tasks/${taskId}/promote # it is not cut — revive the task`,
|
|
97
|
+
'',
|
|
98
|
+
'Close or draft the branch\'s PR either way — the remote is not this tool\'s to change.',
|
|
99
|
+
);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { branchesForTask, leftoversForTask, leftoverNotice };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.810",
|
|
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.810",
|
|
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.810",
|
|
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",
|
package/scripts/gds/abandon.js
CHANGED
|
@@ -21,6 +21,38 @@ const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
|
21
21
|
// task 1003153 — the live-status set, shared with the dead-edge detector so
|
|
22
22
|
// "who did this cut strand" means the same thing in both places.
|
|
23
23
|
const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
|
|
24
|
+
// task 1003779 — the other thing a cut leaves behind: the code itself.
|
|
25
|
+
const { leftoversForTask, leftoverNotice } = require('../../modules/lifecycle/abandon-leftovers.js');
|
|
26
|
+
const { execFileSync } = require('node:child_process');
|
|
27
|
+
|
|
28
|
+
// Best-effort git, always. Every call here is advisory decoration on an abandon
|
|
29
|
+
// that has ALREADY landed, so a missing git, a detached network or a shallow
|
|
30
|
+
// clone must return "I don't know" — never throw, and never abort the command.
|
|
31
|
+
function git(args, ms = 20000) {
|
|
32
|
+
return execFileSync('git', args, { encoding: 'utf8', timeout: ms, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The remote branches this task owns. ONE network round trip, scoped to the two
|
|
36
|
+
// name shapes worktree.js produces — listing all 800-odd heads to filter locally
|
|
37
|
+
// would be the expensive way to learn the same thing.
|
|
38
|
+
function remoteBranchesFor(taskId) {
|
|
39
|
+
try {
|
|
40
|
+
const out = git(['ls-remote', '--heads', 'origin', `refs/heads/task-${taskId}`, `refs/heads/task-${taskId}-*`]);
|
|
41
|
+
return out.split('\n').map((l) => l.split('\t')[1] || '').filter(Boolean)
|
|
42
|
+
.map((r) => r.replace(/^refs\/heads\//, ''));
|
|
43
|
+
} catch (_) { return null; } // null = could not look
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// How many commits the branch carries that main does not. Returns null when it
|
|
47
|
+
// cannot be determined, which leftoversForTask reports as undetermined rather
|
|
48
|
+
// than folding into "clean" — see that module's header.
|
|
49
|
+
function commitsAheadOfMain(branch) {
|
|
50
|
+
try {
|
|
51
|
+
git(['fetch', 'origin', '--quiet', branch, 'main']);
|
|
52
|
+
const n = Number(String(git(['rev-list', '--count', 'FETCH_HEAD', '--not', 'origin/main'])).trim());
|
|
53
|
+
return Number.isFinite(n) ? n : null;
|
|
54
|
+
} catch (_) { return null; }
|
|
55
|
+
}
|
|
24
56
|
|
|
25
57
|
|
|
26
58
|
(async () => {
|
|
@@ -79,6 +111,22 @@ const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
|
|
|
79
111
|
console.log(' Sweep for any you miss: node scripts/gds/audit-deps.js --stranded');
|
|
80
112
|
}
|
|
81
113
|
} catch (_) { /* the abandon already landed — the report is advisory */ }
|
|
114
|
+
|
|
115
|
+
// task 1003779 — REPORT the code this cut orphaned. Abandoning retires the ROW;
|
|
116
|
+
// the branch and its PR stay exactly where they were, reading as live work.
|
|
117
|
+
// Task 1002464 was cut carrying two commits never merged to main, and the
|
|
118
|
+
// follow-up filed off its review (task 1003626) then sat at 'ready' asking for
|
|
119
|
+
// changes to a function that exists nowhere on main — 'sequence.js --check'
|
|
120
|
+
// called it clear to run. Nothing had said the branch was dead, so nothing could.
|
|
121
|
+
// Same posture as the stranded-dependents report above: best-effort, and it
|
|
122
|
+
// never fails an abandon that already landed.
|
|
123
|
+
try {
|
|
124
|
+
const branches = remoteBranchesFor(taskId);
|
|
125
|
+
if (branches && branches.length) {
|
|
126
|
+
const notice = leftoverNotice(taskId, leftoversForTask(taskId, { branches, aheadCount: commitsAheadOfMain }));
|
|
127
|
+
if (notice) console.log(notice);
|
|
128
|
+
}
|
|
129
|
+
} catch (_) { /* advisory only */ }
|
|
82
130
|
})().catch((err) => {
|
|
83
131
|
console.error('fatal:', err);
|
|
84
132
|
process.exit(1);
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
|
|
12
12
|
const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal, staleSummaryNotice } = require('./ship-honesty.js');
|
|
13
13
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
|
-
const { scopeForModule,
|
|
15
|
-
const { matchOne } = require('../../src/bongos/path-match');
|
|
14
|
+
const { isModuleKey, scopeForModule, moduleDriftFiles: computeModuleDrift } = require('../../src/bongos/module-scope-map');
|
|
16
15
|
const { runShipGate } = require('./sandbox-stage');
|
|
17
16
|
const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js');
|
|
18
17
|
// task 1003109: the optional `--visual` leg of a ship (goal 1000072).
|
|
@@ -270,9 +269,18 @@ async function shipMain() {
|
|
|
270
269
|
// ---- Module drift check (BV1.R53, task 1422) ----
|
|
271
270
|
// If the task carries a module_key, verify the committed diff doesn't stray
|
|
272
271
|
// into a DIFFERENT module's territory. "You said game, you touched dev-box" is
|
|
273
|
-
// the semantic violation this catches. Files
|
|
274
|
-
//
|
|
275
|
-
//
|
|
272
|
+
// the semantic violation this catches. Files no module owns are allowed —
|
|
273
|
+
// they're cross-cutting — as is the territory of a CROSS-CUTTING module, which
|
|
274
|
+
// every craft legitimately writes into. Only contact with a different OWNING
|
|
275
|
+
// module's glob set is flagged.
|
|
276
|
+
//
|
|
277
|
+
// The rule itself lives in module-scope-map.js (moduleDriftFiles) next to the
|
|
278
|
+
// map it reads, so it can be tested without running a ship (task 1003770). It
|
|
279
|
+
// used to be inline here, and read "files outside all module scopes (docs,
|
|
280
|
+
// session logs, general scripts) are allowed" — a premise task 1002989 ended by
|
|
281
|
+
// giving `methodology` the whole docs/ subtree. Since every ship writes a
|
|
282
|
+
// session log, the advisory then fired for 25 of the 26 module keys on that log
|
|
283
|
+
// alone. See CROSS_CUTTING_KEYS for why the glob was not shrunk instead.
|
|
276
284
|
//
|
|
277
285
|
// The check is ADVISORY (warns but does not refuse) on the first cut because
|
|
278
286
|
// the module_key column is new and existing tasks may carry a broader diff scope
|
|
@@ -280,24 +288,26 @@ async function shipMain() {
|
|
|
280
288
|
// ac.module_key comes from getActiveClaimsForBuilder (t.module_key in the join).
|
|
281
289
|
const enforceModule = args.includes('--enforce-module');
|
|
282
290
|
if (ac.module_key && isModuleKey(ac.module_key) && committedFiles.length > 0) {
|
|
283
|
-
const
|
|
284
|
-
const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map');
|
|
285
|
-
const moduleDriftFiles = committedFiles.filter((f) => {
|
|
286
|
-
if (declaredScope && matchOne(f, declaredScope.globs)) return false;
|
|
287
|
-
for (const [key, entry] of Object.entries(MODULE_SCOPE_MAP)) {
|
|
288
|
-
if (key === ac.module_key) continue;
|
|
289
|
-
if (matchOne(f, entry.globs)) return true;
|
|
290
|
-
}
|
|
291
|
-
return false;
|
|
292
|
-
});
|
|
291
|
+
const moduleDriftFiles = computeModuleDrift(ac.module_key, committedFiles);
|
|
293
292
|
if (moduleDriftFiles.length > 0) {
|
|
293
|
+
// Name the module's REAL territory rather than guessing 'modules/<key>/'.
|
|
294
|
+
// That guess is wrong for every module whose code has not been carved out
|
|
295
|
+
// yet (ADR 0083) — kernel lives in src/bongos/, methodology in docs/ — so
|
|
296
|
+
// the advisory used to tell those builders to move their work somewhere
|
|
297
|
+
// their module does not own. scopeForModule is the roster's own answer.
|
|
298
|
+
const declared = scopeForModule(ac.module_key);
|
|
299
|
+
const territory = declared && declared.globs.length
|
|
300
|
+
? declared.globs.slice(0, 4).join(', ') + (declared.globs.length > 4 ? `, +${declared.globs.length - 4} more` : '')
|
|
301
|
+
: `modules/${ac.module_key}/`;
|
|
294
302
|
const msg = [
|
|
295
303
|
`\n⚠ Module drift detected: task is scoped to '${ac.module_key}' but ${moduleDriftFiles.length} file(s) touch a different module:`,
|
|
296
304
|
...moduleDriftFiles.map((f) => ` - ${f}`),
|
|
297
305
|
'',
|
|
306
|
+
` '${ac.module_key}' owns: ${territory}`,
|
|
307
|
+
'',
|
|
298
308
|
enforceModule
|
|
299
|
-
?
|
|
300
|
-
: 'Advisory — ship proceeds, but consider confining your changes to
|
|
309
|
+
? `Refused (--enforce-module). Confine your changes to that territory, or clear the module_key label.`
|
|
310
|
+
: 'Advisory — ship proceeds, but consider confining your changes to that territory.',
|
|
301
311
|
].join('\n');
|
|
302
312
|
console.log(msg);
|
|
303
313
|
if (enforceModule) process.exit(1);
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
'use strict';
|
|
38
38
|
|
|
39
39
|
const { PROTECTED_GLOBS } = require('./permission-path-check');
|
|
40
|
-
const { touchesOverlap } = require('./path-match');
|
|
40
|
+
const { touchesOverlap, matchOne } = require('./path-match');
|
|
41
41
|
|
|
42
42
|
// key → the path globs that constitute the module's code (exact paths or
|
|
43
43
|
// trailing-'/' subtree prefixes). See the header for the auto-tighten contract.
|
|
@@ -486,6 +486,62 @@ function scopeIncludesProtected(keys) {
|
|
|
486
486
|
return (keys || []).some((k) => isProtectedModule(k) === true);
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
// ---- CROSS-CUTTING MODULES (task 1003770) ----
|
|
490
|
+
//
|
|
491
|
+
// A module whose territory EVERY craft legitimately writes into, whatever its own
|
|
492
|
+
// task is scoped to. Distinct from SHARED_GLOBS above: a shared glob is owned by
|
|
493
|
+
// nobody, while a cross-cutting module genuinely owns its paths — `methodology`
|
|
494
|
+
// really is the owner of docs/ — it is just that owning them cannot mean other
|
|
495
|
+
// modules are trespassing when they write there.
|
|
496
|
+
//
|
|
497
|
+
// WHAT THIS IS AND IS NOT FOR. This narrows ONE consumer: the ship-time module-
|
|
498
|
+
// drift advisory (moduleDriftFiles below), whose question is "you said game, did
|
|
499
|
+
// you touch dev-box?" — module-to-module confusion. It does NOT touch the goal
|
|
500
|
+
// scope wall, claim collisions (touchesOverlap), or the protected-path wall.
|
|
501
|
+
// Those keep counting methodology's territory as methodology's.
|
|
502
|
+
//
|
|
503
|
+
// WHY IT WAS NEEDED. Task 1002989 gave `methodology` the whole docs/ subtree.
|
|
504
|
+
// Since every ship writes a session log under docs/session-logs/, that made the
|
|
505
|
+
// advisory fire on 25 of the 26 module keys for the session log ALONE — it warned
|
|
506
|
+
// on essentially every module-keyed ship, which is not a signal, it is weather.
|
|
507
|
+
//
|
|
508
|
+
// WHY NOT SHRINK THE GLOB INSTEAD. The glob format has no wildcards (see the
|
|
509
|
+
// header), so "all of docs/ except session-logs" is inexpressible; enumerating
|
|
510
|
+
// docs/ subtree-by-subtree would rot on the next doc.
|
|
511
|
+
//
|
|
512
|
+
// THE BAR, deliberately high — "every task legitimately writes here as a normal
|
|
513
|
+
// part of shipping". `devsecops` is NOT on this list even though it owns
|
|
514
|
+
// package.json: a task that edits .github/, .husky/ or fitness.js while scoped to
|
|
515
|
+
// another module IS the confusion this advisory exists to surface, and lockfile
|
|
516
|
+
// churn is occasional rather than universal.
|
|
517
|
+
const CROSS_CUTTING_KEYS = Object.freeze(['methodology']);
|
|
518
|
+
|
|
519
|
+
function isCrossCuttingModule(key) {
|
|
520
|
+
return CROSS_CUTTING_KEYS.includes(key);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Pure: of `files`, the ones that stray into a DIFFERENT module's territory for a
|
|
524
|
+
// task scoped to `moduleKey`. Extracted from scripts/gds/ship-flow.js (BV1.R53,
|
|
525
|
+
// task 1422) so the rule is testable without running a ship.
|
|
526
|
+
//
|
|
527
|
+
// A file is drift only if it is (a) outside the declared module's own globs and
|
|
528
|
+
// (b) inside some OTHER module's globs — a path no module owns is cross-cutting
|
|
529
|
+
// and always fine. Cross-cutting modules are skipped in (b): their territory
|
|
530
|
+
// never makes a file someone else's. An unknown moduleKey yields no drift, since
|
|
531
|
+
// there is no declared scope to stray from.
|
|
532
|
+
function moduleDriftFiles(moduleKey, files) {
|
|
533
|
+
const declaredScope = scopeForModule(moduleKey);
|
|
534
|
+
if (!declaredScope) return [];
|
|
535
|
+
return (files || []).filter((file) => {
|
|
536
|
+
if (matchOne(file, declaredScope.globs)) return false;
|
|
537
|
+
for (const [key, entry] of Object.entries(MODULE_SCOPE_MAP)) {
|
|
538
|
+
if (key === moduleKey || isCrossCuttingModule(key)) continue;
|
|
539
|
+
if (matchOne(file, entry.globs)) return true;
|
|
540
|
+
}
|
|
541
|
+
return false;
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
489
545
|
module.exports = {
|
|
490
546
|
MODULE_GLOBS,
|
|
491
547
|
MODULE_SCOPE_MAP,
|
|
@@ -498,5 +554,6 @@ module.exports = {
|
|
|
498
554
|
isProtectedModule,
|
|
499
555
|
protectedModules,
|
|
500
556
|
globsForModules,
|
|
557
|
+
moduleDriftFiles,
|
|
501
558
|
scopeIncludesProtected,
|
|
502
559
|
};
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.810'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// tests/abandon_leftovers.mjs — what a retired task leaves on the remote
|
|
2
|
+
// (task 1003779). Pure + git-free: the ancestry oracle is injected, so these run
|
|
3
|
+
// with no repository, no network and no fixtures.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const { branchesForTask, leftoversForTask, leftoverNotice } =
|
|
13
|
+
require(path.join(ROOT, 'modules', 'lifecycle', 'abandon-leftovers.js'));
|
|
14
|
+
|
|
15
|
+
test('both branch shapes worktree.js produces belong to the task', () => {
|
|
16
|
+
const branches = ['task-1002464', 'task-1002503-backfill-recovery', 'main'];
|
|
17
|
+
assert.deepEqual(branchesForTask(1002464, branches), ['task-1002464']);
|
|
18
|
+
assert.deepEqual(branchesForTask(1002503, branches), ['task-1002503-backfill-recovery']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('THE MISREPORT THIS MUST NOT MAKE: a longer id is not a prefix match', () => {
|
|
22
|
+
// task-10024501 is ANOTHER task's branch. Matching it would tell a builder to
|
|
23
|
+
// delete someone else's unmerged work as their own leftovers — worse than
|
|
24
|
+
// reporting nothing at all, which is why the pattern is anchored at both ends.
|
|
25
|
+
assert.deepEqual(branchesForTask(1002450, ['task-10024501', 'task-1002450x', 'xtask-1002450']), []);
|
|
26
|
+
assert.deepEqual(branchesForTask(1002450, ['task-1002450', 'task-10024501']), ['task-1002450']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the same branch given twice is reported once', () => {
|
|
30
|
+
// Callers may pass `git branch -r` output (origin/task-N) and `ls-remote`
|
|
31
|
+
// output (task-N) together; the notice would otherwise say "delete it" twice.
|
|
32
|
+
assert.deepEqual(branchesForTask(1002464, ['task-1002464', 'origin/task-1002464']), ['task-1002464']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a non-numeric or missing id matches nothing rather than building a loose regex', () => {
|
|
36
|
+
for (const id of ['', null, undefined, 'x', '10*', '.*']) {
|
|
37
|
+
assert.deepEqual(branchesForTask(id, ['task-1002464', 'task-x']), [], `id ${JSON.stringify(id)}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a branch already merged into main is not a leftover — the silent path', () => {
|
|
42
|
+
// The load-bearing negative. Every shipped task keeps its branch, so if a
|
|
43
|
+
// merged branch counted, the notice would fire on nearly every abandon and
|
|
44
|
+
// become weather (the lesson task 1003770 recorded about the drift advisory).
|
|
45
|
+
assert.deepEqual(leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: () => 0 }), []);
|
|
46
|
+
assert.equal(leftoverNotice(1002464, []), null, 'nothing to say means say nothing');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('unmerged commits are reported with their count', () => {
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: () => 2 }),
|
|
52
|
+
[{ branch: 'task-1002464', ahead: 2, undetermined: false }],
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('"could not look" is never folded into "nothing there"', () => {
|
|
57
|
+
// A network failure must not silently certify a branch as clean — the
|
|
58
|
+
// strand-watch.js rule. null and a throw both surface as undetermined.
|
|
59
|
+
for (const oracle of [() => null, () => undefined, () => { throw new Error('offline'); }, () => NaN]) {
|
|
60
|
+
const r = leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: oracle });
|
|
61
|
+
assert.equal(r.length, 1);
|
|
62
|
+
assert.equal(r[0].undetermined, true);
|
|
63
|
+
assert.equal(r[0].ahead, null);
|
|
64
|
+
}
|
|
65
|
+
assert.match(leftoverNotice(1002464, leftoversForTask(1002464,
|
|
66
|
+
{ branches: ['task-1002464'], aheadCount: () => null })), /could not tell/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the notice names the task, the branch, and both ways out', () => {
|
|
70
|
+
const notice = leftoverNotice(1002464, leftoversForTask(1002464,
|
|
71
|
+
{ branches: ['task-1002464'], aheadCount: () => 2 }));
|
|
72
|
+
assert.match(notice, /#1002464/);
|
|
73
|
+
assert.match(notice, /task-1002464 — 2 commit\(s\) not on main/);
|
|
74
|
+
// Both dispositions, because which one applies is a human call: the work is
|
|
75
|
+
// cut (drop the branch) or it is not (revive the task).
|
|
76
|
+
assert.match(notice, /git push origin --delete task-1002464/);
|
|
77
|
+
assert.match(notice, /tasks\/1002464\/promote/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a mixed set reports only the branches that actually carry work', () => {
|
|
81
|
+
const branches = ['task-1002464', 'task-1002464-followup'];
|
|
82
|
+
const r = leftoversForTask(1002464, {
|
|
83
|
+
branches,
|
|
84
|
+
aheadCount: (b) => (b === 'task-1002464' ? 3 : 0),
|
|
85
|
+
});
|
|
86
|
+
assert.deepEqual(r, [{ branch: 'task-1002464', ahead: 3, undetermined: false }]);
|
|
87
|
+
});
|
|
@@ -291,3 +291,99 @@ test('the gap this closed: an honest task declaring its own test is now authoriz
|
|
|
291
291
|
const r = gsc.check(['ideas'], ['modules/ideas/inbox.js', 'tests/idea_routing.mjs'], 'thetes');
|
|
292
292
|
assert.equal(r.ok, true, r.reason);
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
// ---- module drift advisory: cross-cutting territory (task 1003770) ----
|
|
297
|
+
|
|
298
|
+
const SESSION_LOG = 'docs/session-logs/2026-09-16-task-1003770-example.md';
|
|
299
|
+
|
|
300
|
+
test('THE BUG: a module-scoped task writing a session log is not drift (task 1003770)', () => {
|
|
301
|
+
// The row's own acceptance criterion. Task 1002989 gave `methodology` the whole
|
|
302
|
+
// docs/ subtree, and every ship writes a session log under it — so the advisory
|
|
303
|
+
// started firing on almost every module-keyed ship.
|
|
304
|
+
for (const key of scope.moduleKeys()) {
|
|
305
|
+
assert.deepEqual(
|
|
306
|
+
scope.moduleDriftFiles(key, [SESSION_LOG]), [],
|
|
307
|
+
`a '${key}' task writing a session log must not read as drift`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('the regression this replaces: the OLD rule flagged 25 of 26 keys on that one file', () => {
|
|
313
|
+
// Reproduce the pre-fix predicate — identical except that it does not skip
|
|
314
|
+
// cross-cutting modules — and prove it fails where the new one passes. Without
|
|
315
|
+
// this, the test above could pass for the wrong reason (e.g. nobody owning
|
|
316
|
+
// docs/) and go quietly vacuous if the glob moved again.
|
|
317
|
+
const { matchOne } = require(path.join(ROOT, 'src', 'bongos', 'path-match.js'));
|
|
318
|
+
const oldRule = (moduleKey, files) => {
|
|
319
|
+
const declared = scope.scopeForModule(moduleKey);
|
|
320
|
+
return files.filter((file) => {
|
|
321
|
+
if (declared && matchOne(file, declared.globs)) return false;
|
|
322
|
+
return Object.entries(scope.MODULE_SCOPE_MAP)
|
|
323
|
+
.some(([k, e]) => k !== moduleKey && matchOne(file, e.globs));
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
const flaggedBefore = scope.moduleKeys().filter((k) => oldRule(k, [SESSION_LOG]).length);
|
|
327
|
+
assert.ok(
|
|
328
|
+
flaggedBefore.length >= 20,
|
|
329
|
+
`expected the old rule to flag most keys on a session log, it flagged ${flaggedBefore.length}`,
|
|
330
|
+
);
|
|
331
|
+
assert.ok(flaggedBefore.includes('lifecycle'), 'sanity: lifecycle was among them');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('a cross-cutting module owns its paths everywhere EXCEPT the drift advisory', () => {
|
|
335
|
+
// The narrowness is the point: this must not become a hole in the scope wall.
|
|
336
|
+
// methodology still genuinely owns docs/ — only moduleDriftFiles looks away.
|
|
337
|
+
// Asserted through behaviour, not through the roster constant: the constant is
|
|
338
|
+
// internal (nothing outside the module reads it), and "docs/ stops counting as
|
|
339
|
+
// someone else's territory" is the property that actually matters.
|
|
340
|
+
assert.deepEqual(scope.moduleDriftFiles('lifecycle', ['docs/architecture.md']), []);
|
|
341
|
+
assert.ok(scope.scopeForModule('methodology').globs.includes('docs/'));
|
|
342
|
+
const owners = Object.entries(scope.MODULE_SCOPE_MAP)
|
|
343
|
+
.filter(([, e]) => e.globs.includes('docs/'))
|
|
344
|
+
.map(([k]) => k);
|
|
345
|
+
assert.deepEqual(owners, ['methodology'], 'docs/ is still owned, not orphaned');
|
|
346
|
+
// And the goal scope wall still places docs/ inside methodology, unchanged.
|
|
347
|
+
assert.equal(gsc.check(['methodology'], ['docs/architecture.md'], 'thetes').ok, true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('real cross-module contact is still flagged — the signal the advisory exists for', () => {
|
|
351
|
+
// "You said lifecycle, you touched dev-box."
|
|
352
|
+
assert.deepEqual(
|
|
353
|
+
scope.moduleDriftFiles('lifecycle', ['modules/dev-box/app/src/box-controller.js']),
|
|
354
|
+
['modules/dev-box/app/src/box-controller.js'],
|
|
355
|
+
);
|
|
356
|
+
// Its own territory, and territory no module owns, both stay quiet.
|
|
357
|
+
assert.deepEqual(scope.moduleDriftFiles('lifecycle', ['modules/lifecycle/rot.js']), []);
|
|
358
|
+
assert.deepEqual(scope.moduleDriftFiles('lifecycle', ['some/unowned/path.txt']), []);
|
|
359
|
+
// A mixed diff reports only the straying file.
|
|
360
|
+
assert.deepEqual(
|
|
361
|
+
scope.moduleDriftFiles('lifecycle', ['modules/lifecycle/rot.js', SESSION_LOG, 'modules/discord/discord-bot.js']),
|
|
362
|
+
['modules/discord/discord-bot.js'],
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('devsecops is deliberately NOT cross-cutting (task 1003770)', () => {
|
|
367
|
+
// The bar is "every task legitimately writes here as part of shipping". Editing
|
|
368
|
+
// .github/ or fitness.js while scoped elsewhere IS the confusion being surfaced,
|
|
369
|
+
// so this stays a signal. Widening the cross-cutting roster to devsecops would
|
|
370
|
+
// red these, which is the point: it should take a deliberate edit, not a shrug.
|
|
371
|
+
for (const f of ['.github/workflows/unit.yml', 'scripts/gds/fitness.js', 'package.json']) {
|
|
372
|
+
assert.deepEqual(scope.moduleDriftFiles('lifecycle', [f]), [f], `${f} must still read as drift`);
|
|
373
|
+
}
|
|
374
|
+
// And exactly one module is cross-cutting: every other key's territory still
|
|
375
|
+
// counts against a lifecycle-scoped task. Proven over the whole roster rather
|
|
376
|
+
// than by naming the constant, so adding a second cross-cutting module fails here.
|
|
377
|
+
const quiet = scope.moduleKeys().filter((k) => k !== 'lifecycle'
|
|
378
|
+
&& scope.moduleDriftFiles('lifecycle', scope.scopeForModule(k).globs).length === 0);
|
|
379
|
+
assert.deepEqual(quiet, ['methodology']);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('moduleDriftFiles degrades safely on junk input', () => {
|
|
383
|
+
// ship-flow calls this with whatever the claim carries; it must never throw.
|
|
384
|
+
assert.deepEqual(scope.moduleDriftFiles('not-a-module', ['modules/dev-box/app.js']), [],
|
|
385
|
+
'an unknown key has no declared scope to stray from');
|
|
386
|
+
assert.deepEqual(scope.moduleDriftFiles('lifecycle', []), []);
|
|
387
|
+
assert.deepEqual(scope.moduleDriftFiles('lifecycle', null), []);
|
|
388
|
+
assert.deepEqual(scope.moduleDriftFiles(null, ['modules/dev-box/app.js']), []);
|
|
389
|
+
});
|