@bongos/core 1.19.700 → 1.19.701
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 +24 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/conflict-resolve.js +52 -2
- package/modules/lifecycle/executed-closure.js +183 -0
- package/modules/lifecycle/publish-reconciler.js +28 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/executed_closure.mjs +257 -0
- package/tests/publish_reconciler.mjs +58 -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.701",
|
|
6
|
+
"core_contract": "1.19.701",
|
|
7
|
+
"source_commit": "6c01baa69654043099e16c9bf0353eff61d2012d",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-13T03:
|
|
9
|
+
"built_at": "2026-09-13T03:22:47.893Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 477,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2130,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2631,
|
|
20
|
+
"tree_sha256": "640bb539971ad78a750aa80116d26c7f1f81fc1b2afb21ef0fd33855189fe10a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2802,7 +2802,7 @@
|
|
|
2802
2802
|
{
|
|
2803
2803
|
"path": "docs/module-api-changelog.md",
|
|
2804
2804
|
"mode": "0000644",
|
|
2805
|
-
"sha256": "
|
|
2805
|
+
"sha256": "4fc39aaaae76afb8153485474fc9f3459a2ee380b96f59d1d693a4a7680db464"
|
|
2806
2806
|
},
|
|
2807
2807
|
{
|
|
2808
2808
|
"path": "docs/modules-contract.md",
|
|
@@ -5642,7 +5642,7 @@
|
|
|
5642
5642
|
{
|
|
5643
5643
|
"path": "modules/lifecycle/conflict-resolve.js",
|
|
5644
5644
|
"mode": "0000644",
|
|
5645
|
-
"sha256": "
|
|
5645
|
+
"sha256": "4114eb2632a95a9b830956d0e476ad92ed6eaaa526d9bd46e7ddba8a6fb566e3"
|
|
5646
5646
|
},
|
|
5647
5647
|
{
|
|
5648
5648
|
"path": "modules/lifecycle/criterion-suggest.js",
|
|
@@ -5729,6 +5729,11 @@
|
|
|
5729
5729
|
"mode": "0000644",
|
|
5730
5730
|
"sha256": "23ee20e57c67adb5d2ec29c654d2bc469434cafa010b375a8a4452ced1df49e3"
|
|
5731
5731
|
},
|
|
5732
|
+
{
|
|
5733
|
+
"path": "modules/lifecycle/executed-closure.js",
|
|
5734
|
+
"mode": "0000644",
|
|
5735
|
+
"sha256": "70be81206f577910d250e70ed0841466bbe329691324156b5b15bb75abcd93d3"
|
|
5736
|
+
},
|
|
5732
5737
|
{
|
|
5733
5738
|
"path": "modules/lifecycle/github-push.js",
|
|
5734
5739
|
"mode": "0000644",
|
|
@@ -5852,7 +5857,7 @@
|
|
|
5852
5857
|
{
|
|
5853
5858
|
"path": "modules/lifecycle/publish-reconciler.js",
|
|
5854
5859
|
"mode": "0000644",
|
|
5855
|
-
"sha256": "
|
|
5860
|
+
"sha256": "9f3e3ae52f9fa49a1f5638d92a4b985d0c23fe0433d917d017aa78876c41ca43"
|
|
5856
5861
|
},
|
|
5857
5862
|
{
|
|
5858
5863
|
"path": "modules/lifecycle/rot.js",
|
|
@@ -7777,12 +7782,12 @@
|
|
|
7777
7782
|
{
|
|
7778
7783
|
"path": "package-lock.json",
|
|
7779
7784
|
"mode": "0000644",
|
|
7780
|
-
"sha256": "
|
|
7785
|
+
"sha256": "f7fb304e4955f8024186863281f2540c2e92299e49b16f7f6ea0f78c6d46d22a"
|
|
7781
7786
|
},
|
|
7782
7787
|
{
|
|
7783
7788
|
"path": "package.json",
|
|
7784
7789
|
"mode": "0000644",
|
|
7785
|
-
"sha256": "
|
|
7790
|
+
"sha256": "be0e756de883b5e3291f721efe8515540771994df5d83f0eb96bd07f87ecf1e8"
|
|
7786
7791
|
},
|
|
7787
7792
|
{
|
|
7788
7793
|
"path": "public-docs/index.html",
|
|
@@ -9547,7 +9552,7 @@
|
|
|
9547
9552
|
{
|
|
9548
9553
|
"path": "src/module-api.js",
|
|
9549
9554
|
"mode": "0000644",
|
|
9550
|
-
"sha256": "
|
|
9555
|
+
"sha256": "2fdd8c72769bba600991d5c886878c7390ef2086f67ad8c1036555e45631a971"
|
|
9551
9556
|
},
|
|
9552
9557
|
{
|
|
9553
9558
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10559,6 +10564,11 @@
|
|
|
10559
10564
|
"mode": "0000644",
|
|
10560
10565
|
"sha256": "d6a78357533fdeea3bc33fe97cfcd27b1d04747c600d0a7800ddfca5199dbb1f"
|
|
10561
10566
|
},
|
|
10567
|
+
{
|
|
10568
|
+
"path": "tests/executed_closure.mjs",
|
|
10569
|
+
"mode": "0000644",
|
|
10570
|
+
"sha256": "9f4b768e74b542e080f2f2d92db3e5d7a05a66e0fe8822528a40e1b9399f178c"
|
|
10571
|
+
},
|
|
10562
10572
|
{
|
|
10563
10573
|
"path": "tests/explore_visible_states.mjs",
|
|
10564
10574
|
"mode": "0000644",
|
|
@@ -12222,7 +12232,7 @@
|
|
|
12222
12232
|
{
|
|
12223
12233
|
"path": "tests/publish_reconciler.mjs",
|
|
12224
12234
|
"mode": "0000644",
|
|
12225
|
-
"sha256": "
|
|
12235
|
+
"sha256": "92806699b18a064233330d844d4a5bd85ae5ebc628231be81bcdc4548063f074"
|
|
12226
12236
|
},
|
|
12227
12237
|
{
|
|
12228
12238
|
"path": "tests/push_path_brief.mjs",
|
|
@@ -1859,5 +1859,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1859
1859
|
landed since 1.19.698 with no explicit bump. run 34734848584. (task 1002620)
|
|
1860
1860
|
1.19.700 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1861
1861
|
landed since 1.19.699 with no explicit bump. run 34735048203. (task 1002620)
|
|
1862
|
+
1.19.701 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1863
|
+
landed since 1.19.700 with no explicit bump. run 34735311256. (task 1002620)
|
|
1862
1864
|
---------------------------------------------------------------------------
|
|
1863
1865
|
```
|
|
@@ -51,6 +51,16 @@ const githubPush = require('./github-push');
|
|
|
51
51
|
const { repoInfo, branding } = require('../../src/module-api');
|
|
52
52
|
const { loadRepoInfo } = repoInfo;
|
|
53
53
|
const { registerMergeDriver } = require('../../scripts/gds/install-git-hooks');
|
|
54
|
+
const { computeExecutedClosure, branchTouchesExecutedCode } = require('./executed-closure');
|
|
55
|
+
|
|
56
|
+
// The closure is a property of the SERVER's trusted tree, not of the branch being
|
|
57
|
+
// resolved, so it is computed once per root and cached. Asking the clone what our code
|
|
58
|
+
// executes would let the branch answer the question the check exists to ask.
|
|
59
|
+
const closureCache = new Map();
|
|
60
|
+
function executedClosure(root) {
|
|
61
|
+
if (!closureCache.has(root)) closureCache.set(root, computeExecutedClosure({ root }));
|
|
62
|
+
return closureCache.get(root);
|
|
63
|
+
}
|
|
54
64
|
|
|
55
65
|
const execFileP = promisify(execFile);
|
|
56
66
|
|
|
@@ -141,7 +151,8 @@ async function hasConflictMarkers(gitIn) {
|
|
|
141
151
|
// { resolved: true, head_sha, branch, regenerated } — merged + pushed
|
|
142
152
|
// { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
|
|
143
153
|
// skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
|
|
144
|
-
// fetch_failed | checkout_failed |
|
|
154
|
+
// fetch_failed | checkout_failed | diff_failed | branch_modifies_executed_code
|
|
155
|
+
// (with executes[]) | markers_remain | push_failed |
|
|
145
156
|
// unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
|
|
146
157
|
// `regenerated` says whether step 7b had to commit post-merge generator drift on top
|
|
147
158
|
// of the merge (task 1003546) — head_sha is that commit when it did.
|
|
@@ -211,6 +222,28 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
211
222
|
if (r.code !== 0) return { resolved: false, skipped: 'checkout_failed' };
|
|
212
223
|
const before = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
213
224
|
|
|
225
|
+
// 5b. REFUSE A BRANCH THAT EDITS THE CODE THIS FUNCTION IS ABOUT TO RUN
|
|
226
|
+
// (task 1003367, audit B1). Everything below executes out of the clone with the
|
|
227
|
+
// live server's environment — the merge driver at step 6 and the generators at
|
|
228
|
+
// 7b — so a branch that rewrote any of it would be running its own code as the
|
|
229
|
+
// server. This check must sit BEFORE the merge, not after it: `git merge` invokes
|
|
230
|
+
// `node scripts/gds/git-merge-regen.js` by RELATIVE path with cwd at the clone
|
|
231
|
+
// root, so the driver is the clone's copy and it runs at step 6.
|
|
232
|
+
//
|
|
233
|
+
// Fail CLOSED and hand the merge to a human. The alternative — scrubbing the
|
|
234
|
+
// clone and running anyway — has to be right about every file it restores, and
|
|
235
|
+
// the closure is 80-odd files deep through src/instance-config and src/bongos/*,
|
|
236
|
+
// not the scripts/ directory alone.
|
|
237
|
+
const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
|
|
238
|
+
if (touched.code !== 0) return { resolved: false, skipped: 'diff_failed' };
|
|
239
|
+
const executes = branchTouchesExecutedCode(
|
|
240
|
+
touched.stdout.split('\n'),
|
|
241
|
+
deps.executedClosure || executedClosure(repoRoot),
|
|
242
|
+
);
|
|
243
|
+
if (executes.length) {
|
|
244
|
+
return { resolved: false, skipped: 'branch_modifies_executed_code', executes };
|
|
245
|
+
}
|
|
246
|
+
|
|
214
247
|
// 6. Merge origin/main into the branch. The otb-regen driver resolves the
|
|
215
248
|
// generated files; union resolves docs/adr/README.md; any genuine conflict
|
|
216
249
|
// leaves the merge non-zero.
|
|
@@ -392,7 +425,8 @@ async function defaultRunGenerators({ work, execFile = execFileP } = {}) {
|
|
|
392
425
|
// is a no-op for the strand.
|
|
393
426
|
// Returns { refreshed: true, head_sha, branch } | { refreshed: false, skipped }.
|
|
394
427
|
// skipped reasons: disabled | unconfigured | clone_failed | fetch_failed |
|
|
395
|
-
// checkout_failed |
|
|
428
|
+
// checkout_failed | diff_failed | branch_modifies_executed_code (with executes[]) |
|
|
429
|
+
// merge_conflict | generator_failed | no_regen_diff |
|
|
396
430
|
// markers_remain | commit_failed | push_failed.
|
|
397
431
|
async function refreshStaleGenerated({ branch } = {}, deps = {}) {
|
|
398
432
|
const disabled = deps.disabled !== undefined ? deps.disabled : process.env.CONFLICT_RESOLVE_DISABLED === '1';
|
|
@@ -441,6 +475,22 @@ async function refreshStaleGenerated({ branch } = {}, deps = {}) {
|
|
|
441
475
|
r = await git(['checkout', '-B', branch, `refs/remotes/origin/${branch}`]);
|
|
442
476
|
if (r.code !== 0) return { refreshed: false, skipped: 'checkout_failed' };
|
|
443
477
|
|
|
478
|
+
// 4b. Same refusal as resolveBranchConflicts step 5b (task 1003367, audit B1). This
|
|
479
|
+
// path registers no merge driver, so its only execution vector is runGenerators
|
|
480
|
+
// at step 6 — but the check still belongs BEFORE the merge, because after it the
|
|
481
|
+
// tree is a mix of both sides and `origin/main...HEAD` no longer answers "what
|
|
482
|
+
// did this BRANCH change". Same closure, same fail-closed posture, so the two
|
|
483
|
+
// heals cannot drift into different security postures.
|
|
484
|
+
const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
|
|
485
|
+
if (touched.code !== 0) return { refreshed: false, skipped: 'diff_failed' };
|
|
486
|
+
const executes = branchTouchesExecutedCode(
|
|
487
|
+
touched.stdout.split('\n'),
|
|
488
|
+
deps.executedClosure || executedClosure(repoRoot),
|
|
489
|
+
);
|
|
490
|
+
if (executes.length) {
|
|
491
|
+
return { refreshed: false, skipped: 'branch_modifies_executed_code', executes };
|
|
492
|
+
}
|
|
493
|
+
|
|
444
494
|
// 5. Merge origin/main. Expected CLEAN for this strand (the PR was mergeable and
|
|
445
495
|
// the failing signal was a stale-generated CHECK, not a conflict). A genuine
|
|
446
496
|
// conflict → abort + report merge_conflict so the resolveBranchConflicts path
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// modules/lifecycle/executed-closure.js — which files does the merge resolver EXECUTE
|
|
2
|
+
// out of a builder's branch? (task 1003367, audit ref B1, 2026-08-29 security audit.)
|
|
3
|
+
//
|
|
4
|
+
// WHY. resolveBranchConflicts and refreshStaleGenerated clone a builder's branch into a
|
|
5
|
+
// temp tree and run code out of it with the LIVE SERVER's environment — DATABASE_URL,
|
|
6
|
+
// BUILDER_SECRET_KEY, GITHUB_PUSH_TOKEN, DISCORD_BOT_TOKEN all inherited. That happens
|
|
7
|
+
// on two separate paths, and the second one is easy to miss:
|
|
8
|
+
//
|
|
9
|
+
// 1. the `otb-regen` MERGE DRIVER. registerMergeDriver writes the driver command
|
|
10
|
+
// `node scripts/gds/git-merge-regen.js %O %A %B %P` with cwd at the clone root, so
|
|
11
|
+
// git runs the CLONE's copy during `git merge` — before any post-merge step.
|
|
12
|
+
// 2. the GENERATORS, run after the merge as execFile(node, [<clone>/scripts/gds/…]).
|
|
13
|
+
//
|
|
14
|
+
// Both resolve their repo root from __dirname, which is why the trusted-code problem
|
|
15
|
+
// cannot be solved by pointing them at the server's own copy: a server-rooted
|
|
16
|
+
// git-merge-regen.js would regenerate the SERVER's checkout instead of the clone.
|
|
17
|
+
//
|
|
18
|
+
// WHAT THIS MODULE IS FOR. The audit prescribed restoring scripts/gds/ from origin/main.
|
|
19
|
+
// That is not sufficient on its own: every generator requires ../../src/instance-config,
|
|
20
|
+
// and gen-api-docs additionally requires src/bongos/route-rank-check, api-prefix and
|
|
21
|
+
// routes/_helpers. A restore scoped to scripts/gds/ leaves those builder-controlled and
|
|
22
|
+
// the hole open. So rather than curate a list by hand, we COMPUTE the transitive
|
|
23
|
+
// require-closure of the real entrypoints and let the callers refuse to auto-resolve a
|
|
24
|
+
// branch that touches any of it.
|
|
25
|
+
//
|
|
26
|
+
// The closure is computed against the SERVER's trusted tree on purpose. The question is
|
|
27
|
+
// "what does OUR code execute", and asking the clone would let the branch answer it.
|
|
28
|
+
//
|
|
29
|
+
// THE RESIDUAL ASSUMPTION, stated so it can be re-checked rather than rediscovered.
|
|
30
|
+
// This is a STATIC walk of string-literal require()s, so it is sound exactly as far as
|
|
31
|
+
// that style holds. A computed require of real code — string concatenation, a template
|
|
32
|
+
// literal, a variable — inside a closure member would be invisible here and the closure
|
|
33
|
+
// would under-report what a branch can make the server run. That is not hypothetical:
|
|
34
|
+
// src/module-loader/loader.js is in the closure and computed-requires every module's
|
|
35
|
+
// route file, but only from loadModules(), which nothing on the generator path calls.
|
|
36
|
+
// tests/executed_closure.mjs pins the known computed requires per file and fails the
|
|
37
|
+
// build on a new one — which catches a change to OUR tree, and is therefore a tripwire,
|
|
38
|
+
// not a widening. If a generator ever starts loading modules, or a closure member
|
|
39
|
+
// switches to dynamic require, this walk must grow to match before it is trusted again.
|
|
40
|
+
//
|
|
41
|
+
// COST. computeExecutedClosure does synchronous readFileSync/statSync over ~80 files.
|
|
42
|
+
// It runs in the live server process, so that is worth knowing — but it is called from a
|
|
43
|
+
// ~5-minute background sweep, never a request path, and conflict-resolve.js caches it per
|
|
44
|
+
// repo root for the process lifetime. Revisit if the entrypoint set grows a lot, or if
|
|
45
|
+
// the cache key ever stops being a constant.
|
|
46
|
+
//
|
|
47
|
+
// Route files are deliberately NOT in here: src/bongos/route-rank-check.js reads them as
|
|
48
|
+
// DATA, never require()s them (ADR 0083's one-way rule, and its own comment says so), and
|
|
49
|
+
// gen-api-docs scans route sources textually. That distinction is what makes a narrow
|
|
50
|
+
// closure correct — the resolver still REGENERATES from the builder's merged tree, it
|
|
51
|
+
// just never EXECUTES it.
|
|
52
|
+
|
|
53
|
+
'use strict';
|
|
54
|
+
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const path = require('node:path');
|
|
57
|
+
|
|
58
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
59
|
+
|
|
60
|
+
// The two execution roots, as repo-relative POSIX paths.
|
|
61
|
+
//
|
|
62
|
+
// BOTH script-tree spellings are listed while the compat shim exists. Task 1003705 moves
|
|
63
|
+
// these to scripts/bongos/ and leaves scripts/gds/ holding WORKING forwarders, which git
|
|
64
|
+
// still executes by the driver's relative path — so a guard that knew only the new name
|
|
65
|
+
// would stop seeing edits to code that still runs. Missing entries are skipped, so this
|
|
66
|
+
// is correct before the rename, during the shim window, and after C3d retires it.
|
|
67
|
+
const ENTRYPOINTS = Object.freeze([
|
|
68
|
+
// 1. the merge driver (runs DURING `git merge`)
|
|
69
|
+
'scripts/gds/git-merge-regen.js',
|
|
70
|
+
'scripts/bongos/git-merge-regen.js',
|
|
71
|
+
// 2. the generators (run after the merge) — mirrors GENERATOR_SCRIPTS in
|
|
72
|
+
// conflict-resolve.js; asserted identical by tests/executed_closure.mjs so the two
|
|
73
|
+
// lists cannot drift apart.
|
|
74
|
+
'scripts/gds/gen-repo-map.js',
|
|
75
|
+
'scripts/bongos/gen-repo-map.js',
|
|
76
|
+
'scripts/gds/gen-file-map.js',
|
|
77
|
+
'scripts/bongos/gen-file-map.js',
|
|
78
|
+
'scripts/gds/gen-session-index.js',
|
|
79
|
+
'scripts/bongos/gen-session-index.js',
|
|
80
|
+
'scripts/gds/copy-inventory.js',
|
|
81
|
+
'scripts/bongos/copy-inventory.js',
|
|
82
|
+
'scripts/gds/gen-api-docs.js',
|
|
83
|
+
'scripts/bongos/gen-api-docs.js',
|
|
84
|
+
'scripts/gds/gen-api-client.js',
|
|
85
|
+
'scripts/bongos/gen-api-client.js',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// Files that are not require()d but still decide what runs. .gitattributes names which
|
|
89
|
+
// paths the otb-regen driver is applied to, so editing it changes what git hands the
|
|
90
|
+
// driver and how much of the tree that driver rewrites during a merge.
|
|
91
|
+
const TRUSTED_NON_CODE = Object.freeze(['.gitattributes']);
|
|
92
|
+
|
|
93
|
+
const REQUIRE_RE = /\brequire\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
94
|
+
|
|
95
|
+
function toPosix(p) {
|
|
96
|
+
return p.split(path.sep).join('/');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve a relative require specifier to a repo-relative file, trying the extensions
|
|
100
|
+
// node would. Returns null for bare/`node:` specifiers and for anything that escapes the
|
|
101
|
+
// repo — a require that leaves the tree is not a file a branch can edit.
|
|
102
|
+
function resolveRelative(fromFileAbs, spec, { root }) {
|
|
103
|
+
if (!spec.startsWith('.')) return null;
|
|
104
|
+
const base = path.resolve(path.dirname(fromFileAbs), spec);
|
|
105
|
+
const candidates = [base, base + '.js', base + '.json', path.join(base, 'index.js')];
|
|
106
|
+
for (const c of candidates) {
|
|
107
|
+
let st;
|
|
108
|
+
try {
|
|
109
|
+
st = fs.statSync(c);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!st.isFile()) continue;
|
|
114
|
+
const rel = toPosix(path.relative(root, c));
|
|
115
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
116
|
+
return rel;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// computeExecutedClosure — every file the resolver can execute out of a cloned branch.
|
|
122
|
+
//
|
|
123
|
+
// A STATIC walk of require() literals. It deliberately does not try to follow a computed
|
|
124
|
+
// require: the one that exists (gen-api-docs' require(path.join(CORE_ROOT,
|
|
125
|
+
// 'package.json'))) resolves to package.json, which is parsed as JSON rather than run.
|
|
126
|
+
// If a computed require of real CODE is ever added, the closure would under-report — so
|
|
127
|
+
// tests/executed_closure.mjs fails the build on any computed require inside the closure,
|
|
128
|
+
// which is the honest way to hold a static analysis to its assumption.
|
|
129
|
+
function computeExecutedClosure({ root = REPO_ROOT, entrypoints = ENTRYPOINTS } = {}) {
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const queue = [];
|
|
132
|
+
|
|
133
|
+
for (const rel of entrypoints) {
|
|
134
|
+
const abs = path.join(root, rel);
|
|
135
|
+
if (fs.existsSync(abs)) queue.push(rel);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
while (queue.length) {
|
|
139
|
+
const rel = queue.shift();
|
|
140
|
+
if (seen.has(rel)) continue;
|
|
141
|
+
seen.add(rel);
|
|
142
|
+
|
|
143
|
+
const abs = path.join(root, rel);
|
|
144
|
+
let src;
|
|
145
|
+
try {
|
|
146
|
+
src = fs.readFileSync(abs, 'utf8');
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
REQUIRE_RE.lastIndex = 0;
|
|
151
|
+
let m;
|
|
152
|
+
while ((m = REQUIRE_RE.exec(src))) {
|
|
153
|
+
const next = resolveRelative(abs, m[2], { root });
|
|
154
|
+
if (next && !seen.has(next)) queue.push(next);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const rel of TRUSTED_NON_CODE) seen.add(rel);
|
|
159
|
+
return Object.freeze([...seen].sort());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// branchTouchesExecutedCode — pure predicate over `git diff --name-only` output.
|
|
163
|
+
//
|
|
164
|
+
// Returns the SORTED INTERSECTION rather than a boolean so the caller can name the
|
|
165
|
+
// offending files in its skip reason. A resolver that refuses without saying which file
|
|
166
|
+
// tripped it is one nobody can act on.
|
|
167
|
+
function branchTouchesExecutedCode(touchedPaths, closure) {
|
|
168
|
+
const inClosure = new Set(closure);
|
|
169
|
+
const hits = [];
|
|
170
|
+
for (const raw of touchedPaths || []) {
|
|
171
|
+
const rel = toPosix(String(raw).trim());
|
|
172
|
+
if (rel && inClosure.has(rel)) hits.push(rel);
|
|
173
|
+
}
|
|
174
|
+
return [...new Set(hits)].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
ENTRYPOINTS,
|
|
179
|
+
TRUSTED_NON_CODE,
|
|
180
|
+
REPO_ROOT,
|
|
181
|
+
computeExecutedClosure,
|
|
182
|
+
branchTouchesExecutedCode,
|
|
183
|
+
};
|
|
@@ -386,6 +386,21 @@ async function runSweep(deps = {}) {
|
|
|
386
386
|
`strand:unresolvable_conflict${files ? `:${files}` : ''}`,
|
|
387
387
|
`PR #${m.number} (${branch}) has a hand-written conflict (${files || 'unknown files'}) only its builder can resolve`
|
|
388
388
|
);
|
|
389
|
+
} else if (rr && rr.skipped === 'branch_modifies_executed_code') {
|
|
390
|
+
// task 1003367 (audit B1): DEFINITIVE in exactly the sense P-3 means it —
|
|
391
|
+
// the resolver refused because the branch edits code the resolver would
|
|
392
|
+
// have executed, and no number of later sweeps changes that. Without this
|
|
393
|
+
// branch the skip fell through both chains with no log and no flag, so the
|
|
394
|
+
// PR retried silently every ~5 min forever. Announcing it is not polish:
|
|
395
|
+
// a refusal nobody is told about is indistinguishable from a resolver that
|
|
396
|
+
// quietly stopped working, and this file's own header says a strand must
|
|
397
|
+
// reach its builder when it is DETECTED.
|
|
398
|
+
const files = (rr.executes || []).slice(0, 5).join(',');
|
|
399
|
+
await announceStrand(
|
|
400
|
+
r,
|
|
401
|
+
`strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
|
|
402
|
+
`PR #${m.number} (${branch}) changes code the merge resolver executes (${files || 'unknown files'}) — it will not be auto-resolved; a human must land it`
|
|
403
|
+
);
|
|
389
404
|
}
|
|
390
405
|
} catch (e) {
|
|
391
406
|
errFn(`[publish-reconciler] conflict-resolve for task ${r.task_id} (non-blocking): ${e && e.message}`);
|
|
@@ -470,6 +485,19 @@ async function runSweep(deps = {}) {
|
|
|
470
485
|
logFn(
|
|
471
486
|
`[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} red (${m.reason}) but regeneration produced no diff — a real check failure, left for a human`
|
|
472
487
|
);
|
|
488
|
+
} else if (rr && rr.skipped === 'branch_modifies_executed_code') {
|
|
489
|
+
// task 1003367 (audit B1). A tree-level determination, so it burns a cap
|
|
490
|
+
// slot like the two above rather than retrying as if it were flaky infra:
|
|
491
|
+
// the answer is the same on every future sweep. Announced for the same
|
|
492
|
+
// reason as the conflict path — silence here is a PR that never lands and
|
|
493
|
+
// never says why.
|
|
494
|
+
attempts.set(branch, n + 1);
|
|
495
|
+
const files = (rr.executes || []).slice(0, 5).join(',');
|
|
496
|
+
await announceStrand(
|
|
497
|
+
r,
|
|
498
|
+
`strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
|
|
499
|
+
`PR #${m.number} (${branch}) changes code the regen heal executes (${files || 'unknown files'}) — it will not be auto-healed; a human must land it`
|
|
500
|
+
);
|
|
473
501
|
}
|
|
474
502
|
} catch (e) {
|
|
475
503
|
errFn(`[publish-reconciler] regen-heal for task ${r.task_id} (non-blocking): ${e && e.message}`);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.701",
|
|
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.701",
|
|
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.701",
|
|
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/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.701'; // 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,257 @@
|
|
|
1
|
+
// tests/executed_closure.mjs — task 1003367, audit ref B1 (2026-08-29 security audit).
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT. resolveBranchConflicts and refreshStaleGenerated clone a builder's branch
|
|
4
|
+
// and run code out of it with the live server's environment. The audit prescribed
|
|
5
|
+
// restoring scripts/gds/ and .gitattributes from origin/main after the merge and before
|
|
6
|
+
// runGenerators. Building it that way would have left the hole open TWICE, and both of
|
|
7
|
+
// those are what these tests pin:
|
|
8
|
+
//
|
|
9
|
+
// TOO NARROW — the generators require ../../src/instance-config, and gen-api-docs also
|
|
10
|
+
// requires src/bongos/route-rank-check, api-prefix and routes/_helpers. A restore
|
|
11
|
+
// scoped to scripts/gds/ leaves those builder-controlled and still executed.
|
|
12
|
+
// TOO LATE — `git merge` runs the otb-regen driver, whose registered command is
|
|
13
|
+
// `node scripts/gds/git-merge-regen.js` by RELATIVE path with cwd at the clone root.
|
|
14
|
+
// That is the clone's copy, and it runs at the merge, before any post-merge step.
|
|
15
|
+
//
|
|
16
|
+
// So the assertions below care about two things: that the closure reaches past
|
|
17
|
+
// scripts/, and that the refusal happens BEFORE git merge is ever issued.
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const ec = require('../modules/lifecycle/executed-closure.js');
|
|
28
|
+
const cr = require('../modules/lifecycle/conflict-resolve.js');
|
|
29
|
+
|
|
30
|
+
// fileURLToPath, not URL.pathname: the latter percent-encodes spaces and leaves a Windows
|
|
31
|
+
// drive behind a leading slash (tests/test_path_guard.mjs fails the build on it).
|
|
32
|
+
const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
33
|
+
const CLOSURE = ec.computeExecutedClosure();
|
|
34
|
+
|
|
35
|
+
// ---- what the closure must reach ------------------------------------------
|
|
36
|
+
|
|
37
|
+
test('the closure reaches the src/ files a scripts/-only restore would have missed', () => {
|
|
38
|
+
// The exact four the audit's prescription would have left builder-writable. If this
|
|
39
|
+
// ever goes quiet it means the generators stopped requiring them, which is a reason to
|
|
40
|
+
// re-derive the fix, not to delete the test.
|
|
41
|
+
for (const rel of [
|
|
42
|
+
'src/instance-config.js',
|
|
43
|
+
'src/bongos/route-rank-check.js',
|
|
44
|
+
'src/bongos/api-prefix.js',
|
|
45
|
+
'src/bongos/routes/_helpers.js',
|
|
46
|
+
]) {
|
|
47
|
+
assert.ok(CLOSURE.includes(rel),
|
|
48
|
+
`${rel} is require()d by a generator the resolver executes, so a branch editing it `
|
|
49
|
+
+ 'runs code as the server — it must be in the executed closure');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the closure includes the MERGE DRIVER, not just the generators', () => {
|
|
54
|
+
// The half the audit's "after the merge" framing missed entirely.
|
|
55
|
+
const driver = CLOSURE.filter((f) => /git-merge-regen\.js$/.test(f));
|
|
56
|
+
assert.ok(driver.length > 0,
|
|
57
|
+
'git-merge-regen.js runs DURING git merge as the clone\'s own copy — omitting it '
|
|
58
|
+
+ 'would leave the earliest execution vector unguarded');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the closure is deep, not just the entrypoints', () => {
|
|
62
|
+
// A static walk that silently failed to follow requires would still "pass" the two
|
|
63
|
+
// tests above if it happened to list them. Depth is the evidence it actually walked:
|
|
64
|
+
// the entrypoints alone are a dozen files.
|
|
65
|
+
assert.ok(CLOSURE.length > 40,
|
|
66
|
+
`closure has only ${CLOSURE.length} files — the require walk is probably not recursing`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('.gitattributes is guarded even though nothing requires it', () => {
|
|
70
|
+
assert.ok(CLOSURE.includes('.gitattributes'),
|
|
71
|
+
'it decides which paths the otb-regen driver is applied to, so it changes what runs');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---- holding the static analysis to its own assumption ---------------------
|
|
75
|
+
|
|
76
|
+
test('every computed require inside the closure is one we have accounted for', () => {
|
|
77
|
+
// computeExecutedClosure follows string-literal requires only, so it is sound exactly
|
|
78
|
+
// as far as that assumption holds. Rather than assert the assumption (it is false —
|
|
79
|
+
// there are computed requires in here), pin the KNOWN set with a reason each, and fail
|
|
80
|
+
// on anything new. A computed require that appears later is the case where the closure
|
|
81
|
+
// would silently under-report what a branch can make the server execute.
|
|
82
|
+
//
|
|
83
|
+
// Why each known one does not widen the closure IN PRACTICE: all of them sit inside a
|
|
84
|
+
// function body, so they resolve when that function is CALLED, and nothing on the
|
|
85
|
+
// generator path calls them. src/module-loader/loader.js is the one worth naming —
|
|
86
|
+
// it is reachable (gen-file-map -> claude-materialize -> bin/bongos.js -> init.js ->
|
|
87
|
+
// module-api -> modules.js -> loader.js) and it computed-requires every module's route
|
|
88
|
+
// file, but only from loadModules(), which generation never invokes. If a generator
|
|
89
|
+
// ever loads modules, this closure must grow to cover modules/**/routes/*.js.
|
|
90
|
+
const ACCOUNTED = new Map([
|
|
91
|
+
['src/module-loader/loader.js', 2], // routes/<key>.js + pollerPath, from loadModules()
|
|
92
|
+
['scripts/gds/onboard.js', 3], // provision/oauth-secret/onboardPlan, inside the verb
|
|
93
|
+
['scripts/gds/package-core.js', 5], // publish-manifest/mirror-redact/secret-scrub/…
|
|
94
|
+
['scripts/gds/upgrade.js', 1], // a `node -e` child, not a require in this process
|
|
95
|
+
['scripts/gds/gen-api-docs.js', 1], // require(path.join(CORE_ROOT,'package.json')) — JSON
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// Strip line comments and single/double-quoted strings first: prose and message strings
|
|
99
|
+
// that merely CONTAIN the word were the bulk of the first run's hits.
|
|
100
|
+
const strip = (src) => src
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((l) => l.replace(/\/\/.*$/, ''))
|
|
103
|
+
.join('\n')
|
|
104
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
105
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
106
|
+
.replace(/`(?:[^`\\]|\\.)*`/g, '``');
|
|
107
|
+
|
|
108
|
+
const found = new Map();
|
|
109
|
+
for (const rel of CLOSURE) {
|
|
110
|
+
if (!rel.endsWith('.js')) continue;
|
|
111
|
+
let src;
|
|
112
|
+
try { src = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { continue; }
|
|
113
|
+
const hits = (strip(src).match(/\brequire\(\s*[^'")\s]/g) || []).length;
|
|
114
|
+
if (hits) found.set(rel, hits);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const unexpected = [...found.keys()].filter((f) => !ACCOUNTED.has(f)).sort();
|
|
118
|
+
assert.deepEqual(unexpected, [],
|
|
119
|
+
'a NEW computed require appeared inside the executed closure — decide whether it can '
|
|
120
|
+
+ 'load code out of a cloned branch before adding it to ACCOUNTED');
|
|
121
|
+
|
|
122
|
+
for (const [file, n] of ACCOUNTED) {
|
|
123
|
+
if (!found.has(file)) continue; // the file left the closure; the next assertion covers that
|
|
124
|
+
assert.equal(found.get(file), n,
|
|
125
|
+
`${file} now has ${found.get(file)} computed require(s), not ${n} — re-check whether `
|
|
126
|
+
+ 'the new one runs on the generator path');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('ENTRYPOINTS covers every generator the resolver actually runs', () => {
|
|
131
|
+
// Two hand-maintained lists in two files is how a new generator gets executed without
|
|
132
|
+
// ever entering the closure. Read conflict-resolve's own list and compare by NAME.
|
|
133
|
+
const src = fs.readFileSync(path.join(REPO_ROOT, 'modules/lifecycle/conflict-resolve.js'), 'utf8');
|
|
134
|
+
const block = src.match(/const GENERATOR_SCRIPTS = \[([\s\S]*?)\];/);
|
|
135
|
+
assert.ok(block, 'could not read GENERATOR_SCRIPTS out of conflict-resolve.js');
|
|
136
|
+
const listed = [...block[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
137
|
+
assert.ok(listed.length > 0, 'GENERATOR_SCRIPTS parsed empty');
|
|
138
|
+
|
|
139
|
+
const basenames = new Set(ec.ENTRYPOINTS.map((p) => p.split('/').pop()));
|
|
140
|
+
for (const rel of listed) {
|
|
141
|
+
assert.ok(basenames.has(rel.split('/').pop()),
|
|
142
|
+
`${rel} is executed by the resolver but is not an executed-closure entrypoint`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('both script-tree spellings are entrypoints while the compat shim exists', () => {
|
|
147
|
+
// Task 1003705 moves these to scripts/bongos/ and leaves scripts/gds/ holding WORKING
|
|
148
|
+
// forwarders. The driver command is a relative path, so the old spelling still
|
|
149
|
+
// executes; a guard that knew only one name would stop seeing edits to live code.
|
|
150
|
+
for (const tree of ['scripts/gds', 'scripts/bongos']) {
|
|
151
|
+
assert.ok(ec.ENTRYPOINTS.some((p) => p === `${tree}/git-merge-regen.js`),
|
|
152
|
+
`${tree}/git-merge-regen.js must be an entrypoint while both trees can execute`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ---- the predicate ---------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
test('branchTouchesExecutedCode returns the sorted, deduped intersection', () => {
|
|
159
|
+
const closure = ['.gitattributes', 'src/a.js', 'src/b.js'];
|
|
160
|
+
assert.deepEqual(
|
|
161
|
+
ec.branchTouchesExecutedCode(['src/b.js', 'docs/x.md', 'src/a.js', 'src/b.js'], closure),
|
|
162
|
+
['src/a.js', 'src/b.js'],
|
|
163
|
+
'the caller names the offending files in its skip reason, so order and dupes matter');
|
|
164
|
+
assert.deepEqual(ec.branchTouchesExecutedCode(['docs/x.md', 'README.md'], closure), [],
|
|
165
|
+
'an ordinary branch must not be refused');
|
|
166
|
+
assert.deepEqual(ec.branchTouchesExecutedCode(['', ' ', null], closure), [],
|
|
167
|
+
'blank lines from git output are not matches');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---- the guard, and WHEN it fires ------------------------------------------
|
|
171
|
+
|
|
172
|
+
// Minimal git seam. Records every command so the ORDER can be asserted -- that is the
|
|
173
|
+
// whole point of this fix, not merely that a refusal happened.
|
|
174
|
+
function mkExec(touched) {
|
|
175
|
+
const calls = [];
|
|
176
|
+
const exec = async (args) => {
|
|
177
|
+
calls.push(args);
|
|
178
|
+
let a = args;
|
|
179
|
+
if (a[0] === '-C') a = a.slice(2);
|
|
180
|
+
const sub = a.join(' ');
|
|
181
|
+
if (a[0] === 'diff' && sub.includes('origin/main...HEAD')) {
|
|
182
|
+
return { code: 0, stdout: `${touched.join('\n')}\n`, stderr: '' };
|
|
183
|
+
}
|
|
184
|
+
if (a[0] === 'rev-parse') return { code: 0, stdout: 'aaaaaaa\n', stderr: '' };
|
|
185
|
+
if (a[0] === 'grep') return { code: 1, stdout: '', stderr: '' };
|
|
186
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
187
|
+
};
|
|
188
|
+
exec.calls = calls;
|
|
189
|
+
return exec;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mkDeps(touched, over = {}) {
|
|
193
|
+
const exec = mkExec(touched);
|
|
194
|
+
return {
|
|
195
|
+
exec,
|
|
196
|
+
executedClosure: ['.gitattributes', 'src/instance-config.js', 'scripts/gds/git-merge-regen.js'],
|
|
197
|
+
runGenerators: async () => ({ code: 0 }),
|
|
198
|
+
githubPush: {
|
|
199
|
+
isConfigured: () => true,
|
|
200
|
+
isSafeBranch: () => true,
|
|
201
|
+
resolveToken: async () => 'tok',
|
|
202
|
+
buildAuthUrl: () => 'https://x-access-token:REDACTED@github.com/o/r.git',
|
|
203
|
+
},
|
|
204
|
+
repoInfo: { owner: 'o', repo: 'r' },
|
|
205
|
+
registerMergeDriver: () => {},
|
|
206
|
+
repoRoot: '/srv/repo',
|
|
207
|
+
mkdtemp: () => '/tmp/otb-closure-test',
|
|
208
|
+
rmtmp: () => {},
|
|
209
|
+
...over,
|
|
210
|
+
_exec: exec,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const ranMerge = (exec) => exec.calls.some((c) => {
|
|
215
|
+
const a = c[0] === '-C' ? c.slice(2) : c;
|
|
216
|
+
return a[0] === 'merge' && a[1] !== '--abort';
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
for (const [label, fn, okKey] of [
|
|
220
|
+
['resolveBranchConflicts', (d) => cr.resolveBranchConflicts({ branch: 'task-1' }, d), 'resolved'],
|
|
221
|
+
['refreshStaleGenerated', (d) => cr.refreshStaleGenerated({ branch: 'task-1' }, d), 'refreshed'],
|
|
222
|
+
]) {
|
|
223
|
+
test(`${label} refuses a branch that edits code it would execute`, async () => {
|
|
224
|
+
const deps = mkDeps(['docs/x.md', 'src/instance-config.js']);
|
|
225
|
+
const r = await fn(deps);
|
|
226
|
+
assert.equal(r[okKey], false);
|
|
227
|
+
assert.equal(r.skipped, 'branch_modifies_executed_code');
|
|
228
|
+
assert.deepEqual(r.executes, ['src/instance-config.js'],
|
|
229
|
+
'the refusal must name the file, or nobody can act on it');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test(`${label} refuses BEFORE running git merge`, async () => {
|
|
233
|
+
// The assertion that distinguishes this fix from the one the audit prescribed. A
|
|
234
|
+
// post-merge restore would already have run the branch's merge driver by now.
|
|
235
|
+
const deps = mkDeps(['scripts/gds/git-merge-regen.js']);
|
|
236
|
+
const r = await fn(deps);
|
|
237
|
+
assert.equal(r.skipped, 'branch_modifies_executed_code');
|
|
238
|
+
assert.equal(ranMerge(deps._exec), false,
|
|
239
|
+
'git merge ran despite the refusal — the clone\'s own merge driver would have '
|
|
240
|
+
+ 'executed, which is the earlier of the two vectors this task closes');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test(`${label} still proceeds for an ordinary branch`, async () => {
|
|
244
|
+
// Red-capability for the two above: if the guard refused everything, they would pass
|
|
245
|
+
// for the wrong reason.
|
|
246
|
+
const deps = mkDeps(['docs/x.md', 'modules/hall-ui/public/shell.js']);
|
|
247
|
+
const r = await fn(deps);
|
|
248
|
+
assert.notEqual(r.skipped, 'branch_modifies_executed_code');
|
|
249
|
+
assert.equal(ranMerge(deps._exec), true, 'an ordinary branch must still be merged');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test(`${label} refuses on a .gitattributes edit`, async () => {
|
|
253
|
+
const deps = mkDeps(['.gitattributes']);
|
|
254
|
+
const r = await fn(deps);
|
|
255
|
+
assert.equal(r.skipped, 'branch_modifies_executed_code');
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -1763,3 +1763,61 @@ test('1003217 an older deps bundle with no `raised` key still logs on reconciled
|
|
|
1763
1763
|
assert.equal(logs.length, 1);
|
|
1764
1764
|
assert.doesNotMatch(logs[0], /raised/, 'no phantom "raised 0" noise when the key is absent');
|
|
1765
1765
|
});
|
|
1766
|
+
|
|
1767
|
+
// ---------------------------------------------------------------------------
|
|
1768
|
+
// task 1003367 (audit B1) — the resolver's new refusal must REACH someone.
|
|
1769
|
+
//
|
|
1770
|
+
// Round 1 of this task's grade failed on exactly this: the guard was written, its
|
|
1771
|
+
// comment said "hand the merge to a human", and no call site told anyone. The skip
|
|
1772
|
+
// reason fell through both if/else chains with no log line and no strand flag, so a
|
|
1773
|
+
// PR stuck on it retried silently every ~5 min forever. These pin the announcement,
|
|
1774
|
+
// not the refusal — the refusal itself lives in tests/executed_closure.mjs.
|
|
1775
|
+
// ---------------------------------------------------------------------------
|
|
1776
|
+
|
|
1777
|
+
test('1003367 a branch that edits resolver-executed code flags the strand immediately, naming the file', async () => {
|
|
1778
|
+
const deps = mkDeps({
|
|
1779
|
+
rows: [{ task_id: 620, builder_id: 6, worktree_name: 'wt-exec', title: 'x', security_sensitive: false,
|
|
1780
|
+
confirmed_at: new Date().toISOString() }],
|
|
1781
|
+
mergeByBranch: { 'claude/wt-exec': { merged: false, merged_now: false, number: 620, reason: 'merge_conflict' } },
|
|
1782
|
+
conflictResolveImpl: async () => ({
|
|
1783
|
+
resolved: false, skipped: 'branch_modifies_executed_code', executes: ['src/instance-config.js'],
|
|
1784
|
+
}),
|
|
1785
|
+
});
|
|
1786
|
+
const r = await reconciler.runSweep(deps);
|
|
1787
|
+
assert.equal(deps._calls.flag.length, 1, 'a definitive refusal is flagged on THIS sweep, not left to the stale WARN');
|
|
1788
|
+
assert.equal(deps._calls.flag[0].taskId, 620);
|
|
1789
|
+
assert.match(deps._calls.flag[0].reason, /^strand:branch_modifies_executed_code:src\/instance-config\.js/,
|
|
1790
|
+
'the reason names the offending file, so the builder knows what to take out of the branch');
|
|
1791
|
+
assert.ok(deps._calls.errors.some((m) => /STRAND task 620/.test(m)),
|
|
1792
|
+
'and it WARNs — an unannounced refusal is indistinguishable from a resolver that quietly stopped');
|
|
1793
|
+
assert.equal(r.reconciled, 0, 'such a PR still never ships');
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
test('1003367 the regen-heal path announces the same refusal rather than retrying it forever', async () => {
|
|
1797
|
+
const deps = mkDeps({
|
|
1798
|
+
rows: [{ task_id: 621, builder_id: 6, worktree_name: 'wt-exec2', title: 'x', security_sensitive: false,
|
|
1799
|
+
confirmed_at: new Date().toISOString() }],
|
|
1800
|
+
statusByBranch: { 'claude/wt-exec2': PENDING },
|
|
1801
|
+
mergeByBranch: { 'claude/wt-exec2': mergeableRed('failing_check:unit') },
|
|
1802
|
+
refreshStaleImpl: async () => ({
|
|
1803
|
+
refreshed: false, skipped: 'branch_modifies_executed_code', executes: ['.gitattributes'],
|
|
1804
|
+
}),
|
|
1805
|
+
});
|
|
1806
|
+
await reconciler.runSweep(deps);
|
|
1807
|
+
assert.equal(deps._calls.flag.length, 1, 'the second heal path must not be the silent one');
|
|
1808
|
+
assert.match(deps._calls.flag[0].reason, /^strand:branch_modifies_executed_code:\.gitattributes/);
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
test('1003367 an ordinary skip on either path still announces NOTHING', async () => {
|
|
1812
|
+
// Red-capability for the two above: if every skip reason announced, they would pass
|
|
1813
|
+
// for the wrong reason and the "definitive vs transient" distinction would be gone.
|
|
1814
|
+
const deps = mkDeps({
|
|
1815
|
+
rows: [{ task_id: 622, builder_id: 6, worktree_name: 'wt-ok', title: 'x', security_sensitive: false,
|
|
1816
|
+
confirmed_at: new Date().toISOString() }],
|
|
1817
|
+
mergeByBranch: { 'claude/wt-ok': { merged: false, merged_now: false, number: 622, reason: 'merge_conflict' } },
|
|
1818
|
+
conflictResolveImpl: async () => ({ resolved: false, skipped: 'clone_failed' }),
|
|
1819
|
+
});
|
|
1820
|
+
await reconciler.runSweep(deps);
|
|
1821
|
+
assert.equal(deps._calls.flag.length, 0,
|
|
1822
|
+
'a transient infra miss must keep retrying quietly, not flag the builder');
|
|
1823
|
+
});
|