@bongos/core 1.19.699 → 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 +44 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +127 -3
- package/docs/api-reference.md +8 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +6 -0
- package/modules/agents/module.json +1 -1
- package/modules/agents/routes/agents.js +177 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -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/agents_routes.mjs +194 -0
- package/tests/executed_closure.mjs +257 -0
- package/tests/publish_reconciler.mjs +58 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// tests/agents_routes.mjs — the agent registry's read surface (task 1002488,
|
|
2
|
+
// goal 1000038 Phase 1).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS LIVES IN tests/ AND NOT modules/agents/tests/. The `agents` module is
|
|
5
|
+
// `default: false`, and run-unit-tests.js discovers a module's own tests only
|
|
6
|
+
// when that module is ENABLED ("it isn't mounted, so its tests don't run
|
|
7
|
+
// either"). On this instance agents is disabled, so a module-local file here
|
|
8
|
+
// would be silently skipped forever — coverage in name only. tests/ always runs.
|
|
9
|
+
// Same call task 1002487 made for tests/agents_validate.mjs; this follows it.
|
|
10
|
+
//
|
|
11
|
+
// No database, no fixtures, no HTTP. The projection is a pure function and the
|
|
12
|
+
// gate is a property of the mounted router, so both are checked directly.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/agents_routes.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const routes = require('../modules/agents/routes/agents.js');
|
|
21
|
+
const validate = require('../modules/agents/lib/validate.js');
|
|
22
|
+
const api = require('../src/module-api.js');
|
|
23
|
+
|
|
24
|
+
const { serializeDefinition } = routes;
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
function t(name, fn) {
|
|
29
|
+
try { fn(); console.log(` PASS ${name}`); passed += 1; }
|
|
30
|
+
catch (err) { console.log(` FAIL ${name}\n ${err.message}`); failed += 1; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A full row as agents-sync would have written it — every column the serializer
|
|
34
|
+
// reads, including the two it must never emit.
|
|
35
|
+
function row(over = {}) {
|
|
36
|
+
return {
|
|
37
|
+
id: 7,
|
|
38
|
+
name: 'historian',
|
|
39
|
+
title: 'The Historian',
|
|
40
|
+
persona: 'PERSONA BODY',
|
|
41
|
+
trigger_type: 'event',
|
|
42
|
+
trigger_spec: { event: 'task.shipped' },
|
|
43
|
+
model_tier: 'routine',
|
|
44
|
+
scope_modules: ['lifecycle'],
|
|
45
|
+
scope_paths: [],
|
|
46
|
+
scope_violation: null,
|
|
47
|
+
source: 'file',
|
|
48
|
+
provenance: 'built-in',
|
|
49
|
+
author_rank: 'archon',
|
|
50
|
+
author_builder_id: 4242,
|
|
51
|
+
source_path: '.claude/agents/historian.md',
|
|
52
|
+
last_synced_at: '2026-09-01T00:00:00.000Z',
|
|
53
|
+
enabled: true,
|
|
54
|
+
created_at: '2026-08-01T00:00:00.000Z',
|
|
55
|
+
updated_at: '2026-09-01T00:00:00.000Z',
|
|
56
|
+
...over,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log('\nthe projection — what a registry read may and may not carry:');
|
|
61
|
+
|
|
62
|
+
t('the LIST omits persona; the DETAIL route serves it', () => {
|
|
63
|
+
assert.ok(!('persona' in serializeDefinition(row())), 'a 32KB body per row must not ride the list');
|
|
64
|
+
assert.equal(serializeDefinition(row(), { includePersona: true }).persona, 'PERSONA BODY');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
t('author_builder_id is NEVER served, on either route', () => {
|
|
68
|
+
// The rank is the fact the spawn gate consults; the id would turn a registry
|
|
69
|
+
// read into a people-directory read, which has its own gate (GET /scouting).
|
|
70
|
+
for (const opts of [{}, { includePersona: true }]) {
|
|
71
|
+
const json = JSON.stringify(serializeDefinition(row(), opts));
|
|
72
|
+
assert.doesNotMatch(json, /4242/, 'the author id leaked into the response');
|
|
73
|
+
assert.doesNotMatch(json, /author_builder_id/, 'the author id key leaked into the response');
|
|
74
|
+
}
|
|
75
|
+
assert.equal(serializeDefinition(row()).author_rank, 'archon', 'the rank IS served — it is the gate-relevant fact');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
t('model_tier is served as the TIER, never a resolved model id', () => {
|
|
79
|
+
// The id a tier resolves to is a deployment fact that changes under the row.
|
|
80
|
+
// A registry answering with a stale model name is worse than one answering
|
|
81
|
+
// with the tier, so this asserts the absence of any model-shaped string.
|
|
82
|
+
const o = serializeDefinition(row());
|
|
83
|
+
assert.equal(o.model_tier, 'routine');
|
|
84
|
+
assert.ok(validate.MODEL_TIERS.includes(o.model_tier));
|
|
85
|
+
assert.doesNotMatch(JSON.stringify(o), /claude-|sonnet|opus|haiku/i, 'a concrete model id must not appear');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
console.log('\n`armed` is DERIVED, because two CHECKs make `enabled` narrower than it reads:');
|
|
89
|
+
|
|
90
|
+
t('a scope-violating row reads as disarmed AND says why', () => {
|
|
91
|
+
// agents_definitions_flagged_not_armed_chk forces enabled=false when a
|
|
92
|
+
// violation is recorded. A caller asking "why did this not fire?" gets the
|
|
93
|
+
// answer in the same object rather than having to know that CHECK exists.
|
|
94
|
+
const o = serializeDefinition(row({ enabled: false, scope_violation: 'scope_paths exceeds module' }));
|
|
95
|
+
assert.equal(o.armed, false);
|
|
96
|
+
assert.equal(o.disarmed_reason, 'scope_violation');
|
|
97
|
+
assert.equal(o.scope.violation, 'scope_paths exceeds module');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
t('an ordinary disabled row is disarmed with no invented reason', () => {
|
|
101
|
+
const o = serializeDefinition(row({ enabled: false }));
|
|
102
|
+
assert.equal(o.armed, false);
|
|
103
|
+
assert.equal(o.disarmed_reason, null, 'absence of a reason must not be reported as a violation');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
t('armed is a strict boolean, not a truthy passthrough', () => {
|
|
107
|
+
assert.equal(serializeDefinition(row({ enabled: true })).armed, true);
|
|
108
|
+
// A driver returning 't'/1 for a boolean column must not read as armed by
|
|
109
|
+
// accident — the fail-safe direction for "is this thing allowed to fire" is
|
|
110
|
+
// false, so the check is `=== true`.
|
|
111
|
+
assert.equal(serializeDefinition(row({ enabled: 1 })).armed, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log('\nshape tolerance — a hand-written db row is still a row:');
|
|
115
|
+
|
|
116
|
+
t('a null/odd trigger_spec degrades to {} instead of throwing', () => {
|
|
117
|
+
assert.deepEqual(serializeDefinition(row({ trigger_spec: null })).trigger.spec, {});
|
|
118
|
+
assert.deepEqual(serializeDefinition(row({ trigger_spec: 'nope' })).trigger.spec, {});
|
|
119
|
+
assert.equal(serializeDefinition(row({ trigger_type: 'on-demand' })).trigger.event, null,
|
|
120
|
+
'an on-demand agent has no event, even when a spec carries one');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
t('null arrays degrade to [], and a missing row serializes to null', () => {
|
|
124
|
+
const o = serializeDefinition(row({ scope_modules: null, scope_paths: null }));
|
|
125
|
+
assert.deepEqual(o.scope.modules, []);
|
|
126
|
+
assert.deepEqual(o.scope.paths, []);
|
|
127
|
+
assert.equal(serializeDefinition(null), null);
|
|
128
|
+
assert.equal(serializeDefinition(undefined), null);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
console.log('\nthe gate — self-gated means requireBuilder, on EVERY route:');
|
|
132
|
+
|
|
133
|
+
t('both routes mount requireBuilder, and neither is left ungated', () => {
|
|
134
|
+
const router = routes();
|
|
135
|
+
const mounted = router.stack.filter((l) => l.route);
|
|
136
|
+
assert.equal(mounted.length, 2, 'exactly two routes — a third needs its own gate and its own row here');
|
|
137
|
+
const paths = mounted.map((l) => l.route.path).sort();
|
|
138
|
+
assert.deepEqual(paths, ['/agents', '/agents/:name']);
|
|
139
|
+
for (const layer of mounted) {
|
|
140
|
+
const handlers = layer.route.stack.map((s) => s.handle);
|
|
141
|
+
assert.ok(handlers.includes(api.requireBuilder),
|
|
142
|
+
`${layer.route.path} does not mount requireBuilder — a registry read must not be anonymous`);
|
|
143
|
+
assert.ok(handlers.length >= 2, `${layer.route.path} has no handler behind its gate`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
t('every mounted route is a GET — this surface writes nothing', () => {
|
|
148
|
+
// The read half is a separate task from authoring on purpose. A write verb
|
|
149
|
+
// appearing here would carry no author-rank clamp and no scope wall.
|
|
150
|
+
for (const layer of routes().stack.filter((l) => l.route)) {
|
|
151
|
+
assert.deepEqual(Object.keys(layer.route.methods), ['get'],
|
|
152
|
+
`${layer.route.path} exposes a non-GET verb on the read surface`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
console.log('\nthe vocabulary is SERVED, not copied into the client:');
|
|
157
|
+
|
|
158
|
+
t('the route file spells no enum of its own', () => {
|
|
159
|
+
// Task 1003453's lesson, applied before the bug rather than after: a filter
|
|
160
|
+
// built from a client-side copy goes stale against the validator and the DB
|
|
161
|
+
// CHECK. The list response carries the enums, and they come from validate.js.
|
|
162
|
+
const src = require('node:fs').readFileSync(new URL('../modules/agents/routes/agents.js', import.meta.url), 'utf8');
|
|
163
|
+
assert.doesNotMatch(src, /\[\s*'event'\s*,\s*'on-demand'\s*\]/, 'trigger types re-spelled in the route');
|
|
164
|
+
assert.doesNotMatch(src, /\[\s*'default'\s*,\s*'routine'\s*\]/, 'model tiers re-spelled in the route');
|
|
165
|
+
assert.match(src, /validate\.TRIGGER_TYPES/);
|
|
166
|
+
assert.match(src, /validate\.MODEL_TIERS/);
|
|
167
|
+
assert.match(src, /validate\.EVENT_CATALOG/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
t('the name guard reuses the validator regex, not a third copy of it', () => {
|
|
171
|
+
// The pattern already exists twice — in validate.js and in the schema's
|
|
172
|
+
// agents_definitions_name_fmt_chk. The route makes a third place to drift.
|
|
173
|
+
assert.ok(validate.NAME_RE instanceof RegExp, 'NAME_RE is exported for the route to share');
|
|
174
|
+
assert.equal(typeof validate.NAME_MAX, 'number');
|
|
175
|
+
assert.ok(validate.NAME_RE.test('historian'));
|
|
176
|
+
assert.equal(validate.NAME_RE.test('Historian'), false, 'the CHECK is lowercase-anchored');
|
|
177
|
+
assert.equal(validate.NAME_RE.test('9lives'), false, 'must start with a letter');
|
|
178
|
+
const src = require('node:fs').readFileSync(new URL('../modules/agents/routes/agents.js', import.meta.url), 'utf8');
|
|
179
|
+
assert.doesNotMatch(src, /\/\^\[a-z\]\[a-z0-9-\]\*\$\//, 'the name regex was re-spelled in the route');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
console.log('\nthe module declares the routes it now ships:');
|
|
183
|
+
|
|
184
|
+
t('module.json contributes the agents route file', () => {
|
|
185
|
+
const manifest = require('../modules/agents/module.json');
|
|
186
|
+
assert.ok(Array.isArray(manifest.contributes.routes), 'contributes.routes is declared');
|
|
187
|
+
assert.ok(manifest.contributes.routes.includes('agents'),
|
|
188
|
+
'the loader mounts modules/agents/routes/<name>.js from this list — without it the file is dead');
|
|
189
|
+
assert.equal(manifest.contributes.migrations, true, 'the existing migrations contribution survives');
|
|
190
|
+
assert.equal(manifest.default, false, 'shipped is not enabled — an instance opts in (ADR 0083)');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
console.log(`\nagents_routes: ${passed} passed, ${failed} failed`);
|
|
194
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -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
|
+
});
|