@bongos/core 1.19.647 → 1.19.649
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 +36 -26
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +5 -0
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-tasks.js +13 -1
- package/modules/lifecycle/db.js +40 -64
- package/modules/lifecycle/routes/task-write-routes.js +28 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-flow.js +69 -10
- package/src/module-api.js +1 -1
- package/src/module-loader/loader.js +47 -2
- package/src/module-seams.js +8 -4
- package/tests/home_bounded_reads.mjs +14 -5
- package/tests/lifecycle_facade_surface.mjs +112 -0
- package/tests/migration_allocation.mjs +28 -0
- package/tests/module_loader.mjs +68 -0
- package/tests/patch_gate_ordering.mjs +4 -0
- package/tests/patch_value_summary_e2e.mjs +142 -0
- package/tests/platform_boot.mjs +53 -0
- package/tests/ship_cannot_lie.mjs +42 -13
|
@@ -20,6 +20,10 @@ const path = require('node:path');
|
|
|
20
20
|
const { validateManifest } = require('./manifest-schema');
|
|
21
21
|
const { satisfies } = require('./semver');
|
|
22
22
|
const { resolveCoreRoot, resolveInstanceRoot } = require('../instance-config');
|
|
23
|
+
// Required DIRECTLY, never through src/module-api.js: the doorway pulls in auth
|
|
24
|
+
// and would re-form the eval-time cycle coreVersion() exists to avoid.
|
|
25
|
+
// src/module-seams.js is pure (it requires nothing), so this is cycle-safe.
|
|
26
|
+
const { verifyPortsSatisfied } = require('../module-seams');
|
|
23
27
|
|
|
24
28
|
// Collapse roots that resolve to the SAME absolute path, preserving order.
|
|
25
29
|
// Normalizes via path.resolve so a cwd that arrives with a trailing '.'/'//'
|
|
@@ -129,6 +133,39 @@ function discoveredRegistry(opts) {
|
|
|
129
133
|
return out;
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
// The ENABLED slice of the discovered set, in discovery order. The one place the
|
|
137
|
+
// `isEnabled` gate is spelled out, so verification and mounting can never disagree
|
|
138
|
+
// about which modules are in play.
|
|
139
|
+
function enabledModules({ isEnabled } = {}, opts) {
|
|
140
|
+
return discovered(opts).loaded.filter(({ key }) => typeof isEnabled !== 'function' || isEnabled(key));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// BOOT-TIME seam verification (BV1.R41), run over the ENABLED manifests before a
|
|
144
|
+
// single module router is mounted. THROWS on an unsatisfiable set — a module that
|
|
145
|
+
// consumes a port no enabled module provides, or a port claimed by two of them.
|
|
146
|
+
//
|
|
147
|
+
// Why it throws rather than warns: without it the failure surfaces as resolve()'s
|
|
148
|
+
// runtime throw the first time the seam is used — an arbitrarily long time after
|
|
149
|
+
// boot, on a request path, in a process that reported healthy. ADR 0150's "fail
|
|
150
|
+
// loud, never boot half-wired" is the same argument: refuse at boot, name the
|
|
151
|
+
// module and the port, and let the operator fix the module set.
|
|
152
|
+
//
|
|
153
|
+
// Only the ENABLED set is checked, deliberately. A disabled module's `consumes` is
|
|
154
|
+
// not a wiring error — it is a module that isn't there.
|
|
155
|
+
function verifyEnabledPorts(enabled) {
|
|
156
|
+
const { ok, errors } = verifyPortsSatisfied(enabled.map(({ key, manifest }) => ({
|
|
157
|
+
key,
|
|
158
|
+
provides: manifest.provides || [],
|
|
159
|
+
consumes: manifest.consumes || [],
|
|
160
|
+
})));
|
|
161
|
+
if (ok) return;
|
|
162
|
+
throw new Error([
|
|
163
|
+
'[module-loader] unsatisfiable module wiring — refusing to boot half-wired:',
|
|
164
|
+
...errors.map((e) => ` - ${e}`),
|
|
165
|
+
'Fix the enabled module set (config/modules.json, or a <PREFIX>_MODULE_<KEY> env override) so every consumed port has exactly one provider.',
|
|
166
|
+
].join('\n'));
|
|
167
|
+
}
|
|
168
|
+
|
|
132
169
|
// Mount each discovered + ENABLED module's route factories onto `router`. A
|
|
133
170
|
// module declares contributes.routes:["box"] → factory at modules/<key>/routes/box.js
|
|
134
171
|
// exporting a function returning an express Router. Auth model: audit is composed
|
|
@@ -136,10 +173,18 @@ function discoveredRegistry(opts) {
|
|
|
136
173
|
// its required rank via api.requireBuilder / api.requireRank (public read routes
|
|
137
174
|
// need no gate). The BV1.R44 fitness check enforces that every write route is
|
|
138
175
|
// rank-gated. Returns the count mounted. No-op when nothing is discovered/enabled.
|
|
176
|
+
//
|
|
177
|
+
// Verifies the seam wiring FIRST (verifyEnabledPorts, above). This is the boot hook
|
|
178
|
+
// the check belongs on because it is the one hook every server that mounts modules
|
|
179
|
+
// runs — the live API, platform-server, and the test harness alike — so the
|
|
180
|
+
// verification cannot be skipped by adding an entrypoint, the way a separately
|
|
181
|
+
// called check could be. It runs before the first `require` of a module's route
|
|
182
|
+
// factory, so a half-wired instance never gets as far as loading module code.
|
|
139
183
|
function mountModuleRoutes(router, { isEnabled } = {}, opts) {
|
|
184
|
+
const enabled = enabledModules({ isEnabled }, opts);
|
|
185
|
+
verifyEnabledPorts(enabled);
|
|
140
186
|
let mounted = 0;
|
|
141
|
-
for (const {
|
|
142
|
-
if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
|
|
187
|
+
for (const { dir, manifest } of enabled) {
|
|
143
188
|
const routeKeys = (manifest.contributes && manifest.contributes.routes) || [];
|
|
144
189
|
for (const rk of routeKeys) {
|
|
145
190
|
const factory = require(path.join(dir, 'routes', `${rk}.js`));
|
package/src/module-seams.js
CHANGED
|
@@ -75,10 +75,14 @@ function listPorts() {
|
|
|
75
75
|
|
|
76
76
|
// BOOT-TIME (manifest-level) verification, distinct from resolve()'s RUNTIME
|
|
77
77
|
// throw: given the ENABLED modules' manifests, confirm every consumed port is
|
|
78
|
-
// provided by some enabled module, and no port has two providers. The loader
|
|
79
|
-
// (BV1.R41) runs this before mounting
|
|
80
|
-
//
|
|
81
|
-
//
|
|
78
|
+
// provided by some enabled module, and no port has two providers. The loader's
|
|
79
|
+
// mountModuleRoutes (BV1.R41) runs this via verifyEnabledPorts before mounting any
|
|
80
|
+
// module router, and THROWS on a non-ok result, so a required capability with no
|
|
81
|
+
// provider is caught at boot with a clear message — not as a surprise throw the
|
|
82
|
+
// first time the seam is used. This function itself only REPORTS: it is pure (no
|
|
83
|
+
// registry mutation, no throw), so it is safe to run anytime, and the loader owns
|
|
84
|
+
// the decision to refuse the boot. Wired + regression-pinned by task 1003820,
|
|
85
|
+
// which found it defined, exported, tested, and called by nothing.
|
|
82
86
|
// manifests: [{ key, provides: [...], consumes: [...] }] → { ok, errors: [...] }.
|
|
83
87
|
function verifyPortsSatisfied(manifests = []) {
|
|
84
88
|
const provided = new Map(); // port -> [providerKey,...]
|
|
@@ -99,16 +99,25 @@ await test('the limit is OPT-IN — the Work Board and /builder-start are untouc
|
|
|
99
99
|
await test('every db.* the claimable handler calls actually exists', () => {
|
|
100
100
|
// The bug this catches, caught the hard way: an earlier cut pushed the limit into the
|
|
101
101
|
// db layer, was reverted, and left the route calling `db.listClaimableTasksPaged` — a
|
|
102
|
-
// function that no longer existed. Every assertion in this file is source text,
|
|
103
|
-
// source text cannot see a missing symbol; a neighbouring suite found it instead.
|
|
102
|
+
// function that no longer existed. Every OTHER assertion in this file is source text,
|
|
103
|
+
// and source text cannot see a missing symbol; a neighbouring suite found it instead.
|
|
104
|
+
//
|
|
105
|
+
// So this one is not source text: it REQUIRES the facade and reads the real key. It
|
|
106
|
+
// used to grep db.js's module.exports block for each name, which was a proxy for
|
|
107
|
+
// exactly this and stopped being a valid one at task 1003817 — db.js now SPREADS
|
|
108
|
+
// db-tasks.js's exports instead of re-listing them, so `listClaimableTasks` is a live
|
|
109
|
+
// key that appears nowhere in the block's text. Requiring is safe here: the lifecycle
|
|
110
|
+
// db family is pool-lazy (tests/lifecycle_facade_surface.mjs relies on the same thing).
|
|
104
111
|
const from = ROUTE.indexOf("router.get('/tasks/claimable'");
|
|
105
112
|
const body = ROUTE.slice(from, ROUTE.indexOf('res.json({ claimable', from));
|
|
106
113
|
const called = [...new Set([...body.matchAll(/\bdb\.([A-Za-z0-9_]+)\s*\(/g)].map((m) => m[1]))];
|
|
107
114
|
assert.ok(called.length > 0, 'the handler calls the db layer');
|
|
108
|
-
const
|
|
109
|
-
const block = dbExports.slice(dbExports.lastIndexOf('module.exports'));
|
|
115
|
+
const db = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
|
|
110
116
|
for (const fn of called) {
|
|
111
|
-
assert.ok(
|
|
117
|
+
assert.ok(
|
|
118
|
+
typeof db[fn] === 'function',
|
|
119
|
+
`db.js must export ${fn} — the route calls it, and this is the runtime check, not a text match`
|
|
120
|
+
);
|
|
112
121
|
}
|
|
113
122
|
});
|
|
114
123
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/lifecycle_facade_surface.mjs
|
|
2
|
+
//
|
|
3
|
+
// The lifecycle facade's TASK surface, pinned (task 1003817).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS. modules/lifecycle/db.js used to re-export db-tasks.js by
|
|
6
|
+
// hand: 31 names destructured at the top purely so module.exports could list
|
|
7
|
+
// them again, with no internal caller for a single one. Two hand-maintained
|
|
8
|
+
// copies of one list is real duplication, and the `duplicate_window_count`
|
|
9
|
+
// ratchet counted it — the two runs were 29 identical normalized lines, one
|
|
10
|
+
// under the 30-line window, so task 1003750 adding `updateTaskPriority` to both
|
|
11
|
+
// tipped it over and cost an owner-decided baseline raise (59 -> 60). The reason
|
|
12
|
+
// recorded in config/fitness-baselines.json told whoever added the NEXT
|
|
13
|
+
// db-tasks function to fix the facade instead of raising it again. Task 1003817
|
|
14
|
+
// added `updateTaskValueSummary`, so this is that fix: db.js now spreads
|
|
15
|
+
// db-tasks.js's exports minus an explicit omit list.
|
|
16
|
+
//
|
|
17
|
+
// A SPREAD IS CHEAPER TO MAINTAIN AND EASIER TO GET WRONG. Nothing stops it from
|
|
18
|
+
// silently WIDENING the module's public surface (publishing an internal that was
|
|
19
|
+
// deliberately withheld) or NARROWING it (a rename in db-tasks.js quietly
|
|
20
|
+
// dropping a name every route calls, which is a runtime `undefined is not a
|
|
21
|
+
// function` in an async Express handler — ADR 0091 §2 records exactly that
|
|
22
|
+
// outage for `LIVE_RANK_LADDER`, where it hung the request until Cloudflare
|
|
23
|
+
// 524'd). The old twin list at least failed loudly at require time. So the
|
|
24
|
+
// safety the list used to provide is re-established here, as an assertion rather
|
|
25
|
+
// than as boilerplate: db.js's task surface must be db-tasks.js's exports minus
|
|
26
|
+
// TASK_FACADE_OMIT, exactly — no more, no fewer.
|
|
27
|
+
//
|
|
28
|
+
// No live DB: requiring the lifecycle db family is pool-lazy.
|
|
29
|
+
//
|
|
30
|
+
// Run: node tests/lifecycle_facade_surface.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
|
|
38
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const repoRoot = join(__dirname, '..');
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
|
|
42
|
+
let passed = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
async function test(name, fn) {
|
|
45
|
+
try {
|
|
46
|
+
await fn();
|
|
47
|
+
console.log(` ok ${name}`);
|
|
48
|
+
passed++;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(` FAIL ${name}`);
|
|
51
|
+
console.error(' ', err.message);
|
|
52
|
+
failed++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const db = require(join(repoRoot, 'modules', 'lifecycle', 'db.js'));
|
|
57
|
+
const dbTasks = require(join(repoRoot, 'modules', 'lifecycle', 'db-tasks.js'));
|
|
58
|
+
const dbSrc = readFileSync(join(repoRoot, 'modules', 'lifecycle', 'db.js'), 'utf8');
|
|
59
|
+
|
|
60
|
+
// The omit list is read from the SOURCE, not re-typed here — a test that hardcodes
|
|
61
|
+
// the same two names cannot notice a third being added without a reason.
|
|
62
|
+
function omitListFromSource(src) {
|
|
63
|
+
const block = src.match(/const TASK_FACADE_OMIT = new Set\(\[([\s\S]*?)\]\)/);
|
|
64
|
+
assert.ok(block, 'db.js must declare TASK_FACADE_OMIT so the withheld names are a named decision');
|
|
65
|
+
return block[1]
|
|
66
|
+
.split('\n')
|
|
67
|
+
.map((l) => l.replace(/\/\/.*/, '').trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((l) => l.replace(/^'|',?$/g, ''));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await test('db.js re-exports every db-tasks export except the named omissions', () => {
|
|
73
|
+
const omit = new Set(omitListFromSource(dbSrc));
|
|
74
|
+
const expected = Object.keys(dbTasks).filter((k) => !omit.has(k)).sort();
|
|
75
|
+
const actual = expected.filter((k) => Object.prototype.hasOwnProperty.call(db, k));
|
|
76
|
+
const missing = expected.filter((k) => !Object.prototype.hasOwnProperty.call(db, k));
|
|
77
|
+
assert.deepEqual(missing, [], `db.js dropped task surface: ${missing.join(', ')} — a route calling one of these gets undefined at runtime, not an error at require`);
|
|
78
|
+
assert.equal(actual.length, expected.length);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await test('and publishes NONE of the omitted internals', () => {
|
|
82
|
+
const omit = omitListFromSource(dbSrc);
|
|
83
|
+
assert.ok(omit.length > 0, 'the omit list is not empty — a bare spread would widen the surface');
|
|
84
|
+
for (const name of omit) {
|
|
85
|
+
assert.ok(
|
|
86
|
+
Object.prototype.hasOwnProperty.call(dbTasks, name),
|
|
87
|
+
`${name} is omitted from a surface it is not on — stale entry, remove it`
|
|
88
|
+
);
|
|
89
|
+
assert.ok(
|
|
90
|
+
!Object.prototype.hasOwnProperty.call(db, name),
|
|
91
|
+
`${name} is on the omit list but reachable through db.js — the spread widened the facade`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('the spread is what wires it, and the twin name list is gone', () => {
|
|
97
|
+
assert.match(dbSrc, /\.\.\.dbTasksFacade,/, 'module.exports spreads the filtered db-tasks surface');
|
|
98
|
+
assert.match(
|
|
99
|
+
dbSrc,
|
|
100
|
+
/const dbTasksFacade = Object\.fromEntries\(\s*Object\.entries\(dbTasks\)\.filter\(\(\[name\]\) => !TASK_FACADE_OMIT\.has\(name\)\)/,
|
|
101
|
+
'the filter is the omit list, not an inline literal'
|
|
102
|
+
);
|
|
103
|
+
// The regression this replaces: a `const { … } = require('./db-tasks.js')`
|
|
104
|
+
// destructure whose only purpose was to feed module.exports.
|
|
105
|
+
assert.ok(
|
|
106
|
+
!/const \{[\s\S]*?\} = require\('\.\/db-tasks\.js'\)/.test(dbSrc),
|
|
107
|
+
'db-tasks.js must not be hand-destructured again — that is the duplication this task removed'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log(`\nlifecycle_facade_surface: ${passed} passed, ${failed} failed`);
|
|
112
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -205,6 +205,7 @@ function shouldReserveMigrationNumber(task) {
|
|
|
205
205
|
[() => db.updateTaskDescription(1, 'edited body'), 'description'], // task 1002647
|
|
206
206
|
[() => db.updateTaskTitle(1, 'corrected title'), 'title'], // task 1002792
|
|
207
207
|
[() => db.updateTaskPriority(1, 2), 'priority'], // task 1003750
|
|
208
|
+
[() => db.updateTaskValueSummary(1, 'corrected summary'), 'value_summary'], // task 1003817
|
|
208
209
|
];
|
|
209
210
|
for (const [call, column] of setters) {
|
|
210
211
|
const before = seen.length;
|
|
@@ -255,6 +256,33 @@ function shouldReserveMigrationNumber(task) {
|
|
|
255
256
|
assert.ok(guardAt > 0 && firstWriteAt > 0 && guardAt < firstWriteAt, 'the priority bounds check must run before the first write, not after');
|
|
256
257
|
});
|
|
257
258
|
|
|
259
|
+
// task 1003817: value_summary was the FOURTH write-once field, and the one
|
|
260
|
+
// that is the public record — the hall, /status and the Discord #ship-news
|
|
261
|
+
// line all read it. It had exactly one writer, the claim-resolve route, so
|
|
262
|
+
// `ship.js --regrade --summary` (no claim, by definition) could correct it in
|
|
263
|
+
// the PR and the merge commit and leave the record holding the first ship's
|
|
264
|
+
// sentence forever.
|
|
265
|
+
await test('PATCH /tasks/:id accepts value_summary so a re-graded summary reaches the record (task 1003817)', () => {
|
|
266
|
+
assert.ok(
|
|
267
|
+
/value_summary: \{ type: 'string', minLength: 1, maxLength: LIMITS\.VALUE_SUMMARY \}/.test(routesSrc),
|
|
268
|
+
'the PATCH schema must declare value_summary with the shared limit — rewritable, never blankable'
|
|
269
|
+
);
|
|
270
|
+
assert.ok(/const hasValueSummary = Object\.prototype\.hasOwnProperty\.call\(body, 'value_summary'\)/.test(routesSrc), 'value_summary is gated like every other field');
|
|
271
|
+
assert.ok(/if \(!hasParent &&[^)]*!hasValueSummary\b/.test(routesSrc), 'a value_summary-only PATCH is a supported request');
|
|
272
|
+
assert.ok(/db\.updateTaskValueSummary\(id, body\.value_summary\.trim\(\)\)/.test(routesSrc), 'the write goes through the allowlisted setter');
|
|
273
|
+
assert.ok(
|
|
274
|
+
/async function updateTaskValueSummary/.test(dbSrc) && /updateTaskValueSummary,/.test(dbSrc),
|
|
275
|
+
'the lifecycle db family must define it and re-export it (the ADR 0093 facade contract)'
|
|
276
|
+
);
|
|
277
|
+
// minLength 1 refuses "" but not " ", and the write trims — so the
|
|
278
|
+
// whitespace guard is what actually keeps the record un-blankable. It must
|
|
279
|
+
// sit above the first write, like the priority bounds check above.
|
|
280
|
+
const blankGuardAt = routesSrc.indexOf("res.fail('bad_value_summary'");
|
|
281
|
+
const firstWriteAt2 = routesSrc.indexOf('await db.updateTaskKind(');
|
|
282
|
+
assert.ok(blankGuardAt > 0, 'a whitespace-only value_summary is refused, not trimmed into a blank record');
|
|
283
|
+
assert.ok(blankGuardAt < firstWriteAt2, 'and that refusal runs before the first write');
|
|
284
|
+
});
|
|
285
|
+
|
|
258
286
|
await test('PATCH /tasks/:id accepts needs_migration so a wrong flag is correctable', () => {
|
|
259
287
|
assert.ok(
|
|
260
288
|
/needs_migration: \{ type: 'boolean' \}/.test(routesSrc),
|
package/tests/module_loader.mjs
CHANGED
|
@@ -155,6 +155,71 @@ test('mountModuleRoutes: mounts an enabled module router, skips a disabled one',
|
|
|
155
155
|
loader._reset();
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// task 1003820 — the mount hook must VERIFY the seam wiring before it mounts.
|
|
160
|
+
//
|
|
161
|
+
// verifyPortsSatisfied was defined, exported through the doorway, and unit-tested
|
|
162
|
+
// in isolation, but no production code called it: a module consuming a port no
|
|
163
|
+
// enabled module provided booted clean and threw later, on a request path, out of
|
|
164
|
+
// resolve(). These pin the CALL, not the function — see also the spawn-boot half
|
|
165
|
+
// in tests/platform_boot.mjs, which is what actually turns red if the call site is
|
|
166
|
+
// deleted (learning 1000154).
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
test('task 1003820: mountModuleRoutes refuses an enabled module consuming an unprovided port', () => {
|
|
170
|
+
loader._reset();
|
|
171
|
+
const factory = "module.exports = () => function moduleRoute(req, res, next) { next(); };";
|
|
172
|
+
const root = fixtureRoot({
|
|
173
|
+
consumer: {
|
|
174
|
+
manifest: validManifest('consumer', { consumes: ['reward'], contributes: { routes: ['main'] } }),
|
|
175
|
+
files: { 'routes/main.js': factory },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const used = [];
|
|
179
|
+
const fakeRouter = { use: (fn) => used.push(fn) };
|
|
180
|
+
assert.throws(
|
|
181
|
+
() => loader.mountModuleRoutes(fakeRouter, { isEnabled: () => true }, { roots: [root], core: '1.0.0' }),
|
|
182
|
+
(e) => /unsatisfiable module wiring/.test(e.message)
|
|
183
|
+
&& /"consumer" consumes port "reward"/.test(e.message),
|
|
184
|
+
'the refusal names the module and the port it cannot satisfy'
|
|
185
|
+
);
|
|
186
|
+
assert.equal(used.length, 0, 'it refuses BEFORE requiring any module route factory');
|
|
187
|
+
loader._reset();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('task 1003820: a DISABLED module’s unsatisfied consumes is not a wiring error', () => {
|
|
191
|
+
loader._reset();
|
|
192
|
+
const factory = "module.exports = () => function moduleRoute(req, res, next) { next(); };";
|
|
193
|
+
const root = fixtureRoot({
|
|
194
|
+
consumer: { manifest: validManifest('consumer', { consumes: ['reward'] }) },
|
|
195
|
+
plain: {
|
|
196
|
+
manifest: validManifest('plain', { contributes: { routes: ['main'] } }),
|
|
197
|
+
files: { 'routes/main.js': factory },
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const used = [];
|
|
201
|
+
const fakeRouter = { use: (fn) => used.push(fn) };
|
|
202
|
+
// Only the ENABLED set is verified: a module that is off is a module that is
|
|
203
|
+
// not there, so its consumes names nothing that has to be provided.
|
|
204
|
+
const mounted = loader.mountModuleRoutes(fakeRouter, { isEnabled: (k) => k === 'plain' }, { roots: [root], core: '1.0.0' });
|
|
205
|
+
assert.equal(mounted, 1);
|
|
206
|
+
loader._reset();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('task 1003820: mountModuleRoutes refuses two enabled providers of one port', () => {
|
|
210
|
+
loader._reset();
|
|
211
|
+
const root = fixtureRoot({
|
|
212
|
+
a: { manifest: validManifest('a', { provides: ['reward'] }) },
|
|
213
|
+
b: { manifest: validManifest('b', { provides: ['reward'] }) },
|
|
214
|
+
});
|
|
215
|
+
assert.throws(
|
|
216
|
+
() => loader.mountModuleRoutes({ use: () => {} }, { isEnabled: () => true }, { roots: [root], core: '1.0.0' }),
|
|
217
|
+
(e) => /provided by more than one enabled module/.test(e.message),
|
|
218
|
+
'the single-provider invariant is enforced at boot, not at first resolve()'
|
|
219
|
+
);
|
|
220
|
+
loader._reset();
|
|
221
|
+
});
|
|
222
|
+
|
|
158
223
|
test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49/R72)', () => {
|
|
159
224
|
// dev-box graduated first (modules/dev-box/, BV1.R43), art-pipeline next
|
|
160
225
|
// (BV1.R48 / task 1417), discord next (BV1.R47 / task 1416), game next
|
|
@@ -352,6 +417,9 @@ test('the demo proof module mounts + serves through the loader (BV1.R52 / task 1
|
|
|
352
417
|
|
|
353
418
|
const used = [];
|
|
354
419
|
const fakeRouter = { use: (fn) => used.push(fn) };
|
|
420
|
+
// Since task 1003820 this also proves the SHIPPED enabled set is satisfiable:
|
|
421
|
+
// mountModuleRoutes verifies the seam wiring first and throws on a half-wired
|
|
422
|
+
// set, so a manifest change that strands a consumed port reds this test.
|
|
355
423
|
const mounted = loader.mountModuleRoutes(fakeRouter, { isEnabled: isModuleEnabled });
|
|
356
424
|
assert.ok(mounted >= 1, 'at least the demo router mounted');
|
|
357
425
|
|
|
@@ -49,6 +49,10 @@ const REFUSALS = [
|
|
|
49
49
|
// task 1003750: priority joined the PATCH surface; its bounds check is a
|
|
50
50
|
// validation refusal like the rest, so it must settle before the first write.
|
|
51
51
|
"'bad_priority'",
|
|
52
|
+
// task 1003817: value_summary joined the PATCH surface. Its refusal is the
|
|
53
|
+
// blank guard — minLength 1 stops "" but not " ", and the write trims — so
|
|
54
|
+
// it must settle before the first write like every other validation.
|
|
55
|
+
"'bad_value_summary'",
|
|
52
56
|
'badKind(res)',
|
|
53
57
|
"'bad_discipline'",
|
|
54
58
|
];
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// tests/patch_value_summary_e2e.mjs — PATCH /tasks/:id value_summary over real
|
|
2
|
+
// HTTP (task 1003817).
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND FILE. migration_allocation.mjs pins the two halves in isolation:
|
|
5
|
+
// the route's schema/gate/write as SOURCE TEXT, and db.updateTaskValueSummary
|
|
6
|
+
// driven against a stubbed pool so the real `SET value_summary =` SQL is proven.
|
|
7
|
+
// Neither exercises the WIRING, and the wiring is where this class of change
|
|
8
|
+
// fails — the whole defect being fixed is a value that reached three consumers
|
|
9
|
+
// and never reached the row. Specifically, source text cannot see that the
|
|
10
|
+
// route's STRICT validator (ADR 0118) actually admits the new field: before this
|
|
11
|
+
// task, `value_summary` on this route was a hard 400 `unknown_field`, and a
|
|
12
|
+
// schema entry that is subtly wrong (mis-typed, mis-named, declared on the
|
|
13
|
+
// create schema instead) fails exactly the same way while every text assertion
|
|
14
|
+
// still passes.
|
|
15
|
+
//
|
|
16
|
+
// So this drives the REAL router over a real socket with the db layer stubbed at
|
|
17
|
+
// the module boundary, the way task_visual_route_gate.mjs and
|
|
18
|
+
// goal_archive_route_e2e.mjs do — the router under test is the shipped one.
|
|
19
|
+
//
|
|
20
|
+
// THE CASES WORTH THE FILE: (2) proves acceptance 1 end to end — a corrected
|
|
21
|
+
// summary reaches the write. (4) and (5) prove acceptance 2 from the other side:
|
|
22
|
+
// an OMITTED field must not touch the row at all, and a whitespace-only one must
|
|
23
|
+
// be refused rather than trimmed into a blank record. `value_summary` is the
|
|
24
|
+
// public shipped-value sentence, so blanking it is the one failure mode that is
|
|
25
|
+
// worse than the bug.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/patch_value_summary_e2e.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
process.env.NODE_ENV = 'test';
|
|
33
|
+
|
|
34
|
+
const express = require('express');
|
|
35
|
+
const api = require('../src/module-api.js');
|
|
36
|
+
const db = require('../modules/lifecycle/db.js');
|
|
37
|
+
|
|
38
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
|
|
39
|
+
api.requirePermission = () => (req, _res, next) => next();
|
|
40
|
+
|
|
41
|
+
const TASK = { id: 5150, title: 'a task', status: 'completed', version_id: 'BONGOS-V2', value_summary: 'the FIRST ship sentence' };
|
|
42
|
+
|
|
43
|
+
// Every write the handler can reach, recorded rather than performed — so a test
|
|
44
|
+
// can assert not only what WAS written but that nothing else was.
|
|
45
|
+
let writes = [];
|
|
46
|
+
db.getTask = async () => ({ ...TASK });
|
|
47
|
+
db.updateTaskValueSummary = async (id, v) => { writes.push({ fn: 'value_summary', id, v }); return { ...TASK, value_summary: v }; };
|
|
48
|
+
db.updateTaskTitle = async (id, v) => { writes.push({ fn: 'title', id, v }); return { ...TASK, title: v }; };
|
|
49
|
+
db.updateTaskKind = async (id, v) => { writes.push({ fn: 'kind', id, v }); return { ...TASK, kind: v }; };
|
|
50
|
+
|
|
51
|
+
const app = express();
|
|
52
|
+
app.use(express.json());
|
|
53
|
+
app.use((req, res, next) => {
|
|
54
|
+
res.fail = (code, statusOrOpts, details) => {
|
|
55
|
+
const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
|
|
56
|
+
return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
|
|
57
|
+
};
|
|
58
|
+
next();
|
|
59
|
+
});
|
|
60
|
+
app.use('/api/gds', require('../modules/lifecycle/routes/tasks.js')());
|
|
61
|
+
const server = app.listen(0);
|
|
62
|
+
await new Promise((r) => server.once('listening', r));
|
|
63
|
+
const base = `http://127.0.0.1:${server.address().port}/api/gds`;
|
|
64
|
+
|
|
65
|
+
const patch = async (body) => {
|
|
66
|
+
writes = [];
|
|
67
|
+
const r = await fetch(`${base}/tasks/${TASK.id}`, {
|
|
68
|
+
method: 'PATCH',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
return { status: r.status, body: await r.json().catch(() => null), writes };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let passed = 0;
|
|
76
|
+
let failed = 0;
|
|
77
|
+
async function t(name, fn) {
|
|
78
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
79
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('PATCH /tasks/:id value_summary (task 1003817):');
|
|
83
|
+
|
|
84
|
+
// 1. The regression guard: this is what the route used to do with the field.
|
|
85
|
+
await t('the field is no longer rejected as an unknown field', async () => {
|
|
86
|
+
const r = await patch({ value_summary: 'anything' });
|
|
87
|
+
assert.notEqual(r.body && r.body.error, 'unknown_field',
|
|
88
|
+
'the strict validator must ADMIT value_summary — a missing schema entry looks exactly like the original bug');
|
|
89
|
+
assert.notEqual(r.body && r.body.error, 'no_supported_fields',
|
|
90
|
+
'and the supported-fields guard must count it, or a value_summary-only PATCH is refused');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 2. ACCEPTANCE 1, end to end.
|
|
94
|
+
await t('a corrected summary reaches the write, trimmed', async () => {
|
|
95
|
+
const r = await patch({ value_summary: ' the CORRECTED sentence ' });
|
|
96
|
+
assert.equal(r.status, 200, `expected 200, got ${r.status} ${JSON.stringify(r.body)}`);
|
|
97
|
+
assert.deepEqual(r.writes, [{ fn: 'value_summary', id: TASK.id, v: 'the CORRECTED sentence' }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 3. It composes with the other planning fields rather than replacing them.
|
|
101
|
+
await t('it can travel with another field in one body', async () => {
|
|
102
|
+
const r = await patch({ value_summary: 'both', title: 'new title' });
|
|
103
|
+
assert.equal(r.status, 200);
|
|
104
|
+
const fns = r.writes.map((w) => w.fn).sort();
|
|
105
|
+
assert.deepEqual(fns, ['title', 'value_summary']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// 4. ACCEPTANCE 2, from the route's side.
|
|
109
|
+
await t('ACCEPTANCE 2: an omitted value_summary writes nothing at all', async () => {
|
|
110
|
+
const r = await patch({ title: 'only the title' });
|
|
111
|
+
assert.equal(r.status, 200);
|
|
112
|
+
assert.deepEqual(r.writes.filter((w) => w.fn === 'value_summary'), [],
|
|
113
|
+
'an omitted field must not reach the column — not even to write back what was there');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 5. The failure mode worse than the bug.
|
|
117
|
+
await t('a blank or whitespace-only summary is REFUSED, never written', async () => {
|
|
118
|
+
for (const bad of ['', ' ', '\t\n ']) {
|
|
119
|
+
const r = await patch({ value_summary: bad });
|
|
120
|
+
assert.equal(r.status, 400, `${JSON.stringify(bad)} must be refused, got ${r.status}`);
|
|
121
|
+
assert.deepEqual(r.writes, [], `${JSON.stringify(bad)} must not reach the column`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// 6. And the refusal has to be diagnosable, since the caller is usually a script.
|
|
126
|
+
await t('the refusal names the field and says omission is how you leave it alone', async () => {
|
|
127
|
+
const r = await patch({ value_summary: ' ' });
|
|
128
|
+
assert.equal(r.body.error, 'bad_value_summary');
|
|
129
|
+
assert.match(r.body.message, /never blanked/i);
|
|
130
|
+
assert.match(r.body.message, /omit/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 7. The cap is the shared one, not a number re-typed on this route.
|
|
134
|
+
await t('an over-long summary is refused by the shared LIMITS cap', async () => {
|
|
135
|
+
const r = await patch({ value_summary: 'x'.repeat(api.LIMITS.VALUE_SUMMARY + 1) });
|
|
136
|
+
assert.equal(r.status, 400);
|
|
137
|
+
assert.deepEqual(r.writes, []);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
server.close();
|
|
141
|
+
console.log(`\npatch_value_summary_e2e: ${passed} passed, ${failed} failed`);
|
|
142
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tests/platform_boot.mjs
CHANGED
|
@@ -284,3 +284,56 @@ test('task 1002419: a real `node src/platform-server.js` boot announces the reco
|
|
|
284
284
|
child.kill('SIGKILL');
|
|
285
285
|
}
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// task 1003820 — a half-wired module set must REFUSE the boot.
|
|
290
|
+
//
|
|
291
|
+
// The mutation-proof half of the wiring guard. tests/module_loader.mjs calls
|
|
292
|
+
// mountModuleRoutes directly, so it stays green if the verifyEnabledPorts call
|
|
293
|
+
// site is deleted; this spawns the real entrypoint the way docker-entrypoint.sh
|
|
294
|
+
// does and asserts the process dies with the wiring error, so removing the call
|
|
295
|
+
// turns it red (learning 1000154 — the same shape as task 1002419 above).
|
|
296
|
+
//
|
|
297
|
+
// The unsatisfiable set is built from REAL modules via a documented env knob, not
|
|
298
|
+
// a fixture: economy provides the `reward` port and ideas consumes it, both
|
|
299
|
+
// default:true, so turning economy off strands ideas. DB-free — the refusal
|
|
300
|
+
// happens while the router is being built, long before anything opens a socket.
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
test('task 1003820: platform-server refuses to boot when an enabled module consumes an unprovided port', async () => {
|
|
304
|
+
const port = await freePort();
|
|
305
|
+
const child = spawn(process.execPath, [path.join(REPO_ROOT, 'src', 'platform-server.js')], {
|
|
306
|
+
cwd: REPO_ROOT,
|
|
307
|
+
env: {
|
|
308
|
+
...process.env,
|
|
309
|
+
PORT: String(port),
|
|
310
|
+
HOST: '127.0.0.1',
|
|
311
|
+
// economy provides "reward"; ideas consumes it and stays on.
|
|
312
|
+
CLOUDBONGOS_MODULE_ECONOMY: '0',
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
let out = '';
|
|
317
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
318
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
319
|
+
|
|
320
|
+
// SIGKILL in a finally: if the guard regresses, the child boots CLEANLY and never
|
|
321
|
+
// exits, so without this the suite would hang on a live server instead of failing.
|
|
322
|
+
try {
|
|
323
|
+
const code = await new Promise((resolve, reject) => {
|
|
324
|
+
const timer = setTimeout(
|
|
325
|
+
() => reject(new Error(`platform-server stayed up; it must refuse a half-wired boot. Log:${out}`)),
|
|
326
|
+
20_000
|
|
327
|
+
);
|
|
328
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
329
|
+
child.on('exit', (c) => { clearTimeout(timer); resolve(c); });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
assert.notEqual(code, 0, `the boot must FAIL, not come up half-wired (exit ${code}). Log:${out}`);
|
|
333
|
+
assert.match(out, /unsatisfiable module wiring/, 'the refusal says what is wrong');
|
|
334
|
+
assert.match(out, /"ideas" consumes port "reward"/, 'it names the module and the port');
|
|
335
|
+
assert.doesNotMatch(out, /listening/, 'it never reaches the listen announcement');
|
|
336
|
+
} finally {
|
|
337
|
+
child.kill('SIGKILL');
|
|
338
|
+
}
|
|
339
|
+
});
|
|
@@ -242,15 +242,16 @@ test('the refusal is wired into the regrade path, before any grade spend', () =>
|
|
|
242
242
|
.replace(/\/\/[^\n]*/g, '')), 'usage does not offer --notes');
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
test('ACCEPTANCE 3: --summary reaches the
|
|
245
|
+
test('ACCEPTANCE 3: --summary reaches the grader AND the task row on a re-grade', () => {
|
|
246
246
|
// Task 1003702's third acceptance asked whether --summary reaches the stored
|
|
247
|
-
// value_summary on a re-grade. It
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
247
|
+
// value_summary on a re-grade. It did NOT — a separate defect, filed and fixed
|
|
248
|
+
// as task 1003817, which added value_summary to the PATCH /tasks/:id schema and
|
|
249
|
+
// the persistRegradeSummary call below. This test used to pin the ABSENCE of a
|
|
250
|
+
// writer; it now pins its PRESENCE, for the reason this whole file exists: the
|
|
251
|
+
// panel reads the DIFF, so an answer that lives only in ship notes is an answer
|
|
252
|
+
// nobody can check — which is the very bug task 1003702 was.
|
|
252
253
|
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
253
|
-
const regrade = src.slice(src.indexOf('async function
|
|
254
|
+
const regrade = src.slice(src.indexOf('async function persistRegradeSummary'));
|
|
254
255
|
const code = regrade.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not write
|
|
255
256
|
|
|
256
257
|
// Half one — the threading is intact, so nothing regressed: --summary still
|
|
@@ -261,10 +262,38 @@ test('ACCEPTANCE 3: --summary reaches the GRADER on a re-grade but never the tas
|
|
|
261
262
|
'the summary still reaches the grader prompt');
|
|
262
263
|
assert.match(code, /valueSummary: summary/, 'and the completion card');
|
|
263
264
|
|
|
264
|
-
// Half two — and there
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
assert.
|
|
269
|
-
'regradeMain
|
|
265
|
+
// Half two — and now there IS a writer, wired into the path (the (1) lesson:
|
|
266
|
+
// a helper nothing calls is not a fix).
|
|
267
|
+
assert.match(code, /patchTasksId\(\{[\s\S]{0,80}?body: \{ value_summary: next \}/,
|
|
268
|
+
'persistRegradeSummary PATCHes value_summary onto the task row');
|
|
269
|
+
assert.match(code, /await persistRegradeSummary\(\{ taskId, summaryArg, task \}\)/,
|
|
270
|
+
'and regradeMain actually calls it');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('a re-grade that omits --summary leaves the stored value_summary alone', () => {
|
|
274
|
+
// ACCEPTANCE 2. The row must survive an omitted flag: `summary` falls back to
|
|
275
|
+
// task.value_summary, so writing that fallback back would be a no-op at best
|
|
276
|
+
// and, on any future refactor of the fallback, a way to overwrite the record
|
|
277
|
+
// with something derived. persistRegradeSummary keys on summaryArg — the RAW
|
|
278
|
+
// flag — not on the resolved `summary`, and returns before the PATCH when it
|
|
279
|
+
// is absent. Pinned as source structure because the alternative (a live
|
|
280
|
+
// re-grade) costs a 4-worker panel run.
|
|
281
|
+
const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
|
|
282
|
+
const start = src.indexOf('async function persistRegradeSummary');
|
|
283
|
+
const fn = src.slice(start, src.indexOf('async function regradeMain', start));
|
|
284
|
+
const code = fn.replace(/\/\/[^\n]*/g, '');
|
|
285
|
+
|
|
286
|
+
// It reads the raw flag, never the resolved fallback.
|
|
287
|
+
assert.match(code, /const next = \(summaryArg \|\| ''\)\.trim\(\)/,
|
|
288
|
+
'the guard keys on the raw --summary, not the stored-value fallback');
|
|
289
|
+
|
|
290
|
+
// Both early returns precede the only PATCH in the function.
|
|
291
|
+
const omitted = code.indexOf("reason: 'omitted'");
|
|
292
|
+
const unchanged = code.indexOf("reason: 'unchanged'");
|
|
293
|
+
const patch = code.indexOf('patchTasksId');
|
|
294
|
+
assert.ok(omitted !== -1 && omitted < patch, 'an omitted --summary returns before the write');
|
|
295
|
+
assert.ok(unchanged !== -1 && unchanged < patch, 'an unchanged --summary returns before the write too');
|
|
296
|
+
|
|
297
|
+
// And a failed write never aborts the re-grade — no process.exit, no throw.
|
|
298
|
+
assert.ok(!/process\.exit/.test(code), 'a summary that will not persist must not kill the re-grade');
|
|
270
299
|
});
|