@bongos/core 1.19.625 → 1.19.627
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 +56 -31
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/docs/adr/0270-rank-floor-derives-without-a-prediction.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_236_rank_floor_backfill.sql +56 -0
- package/modules/hall-ui/public/goal-inbox.js +12 -1
- package/modules/ideas/routes/inbox.js +36 -2
- package/modules/lifecycle/db-rank-authz.js +27 -2
- package/modules/lifecycle/db-tasks.js +82 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/gen-api-docs.js +28 -4
- package/scripts/gds/route-shadow-guard.js +175 -0
- package/src/bongos/route-rank-check.js +29 -3
- package/src/module-api.js +1 -1
- package/tests/api_docs.mjs +28 -0
- package/tests/helpers.mjs +28 -0
- package/tests/idea_hall_composer.mjs +5 -4
- package/tests/idea_routing.mjs +4 -5
- package/tests/idea_spark_queue.mjs +5 -8
- package/tests/profile_activity.mjs +4 -5
- package/tests/prose_edits.mjs +6 -1
- package/tests/rank_floor_wiring.mjs +135 -0
- package/tests/rank_gate.mjs +45 -0
- package/tests/route_shadow_guard.mjs +215 -0
|
@@ -35,6 +35,7 @@ import fs from 'node:fs';
|
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import vm from 'node:vm';
|
|
37
37
|
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { assertCannotSwallow } from './helpers.mjs';
|
|
38
39
|
|
|
39
40
|
const require = createRequire(import.meta.url);
|
|
40
41
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -79,11 +80,9 @@ const ideaRouter = ideaRoutesFactory({});
|
|
|
79
80
|
const mountedPaths = ideaRouter.stack.filter((l) => l.route).map((l) => l.route.path);
|
|
80
81
|
|
|
81
82
|
await test('GET /inbox/by-builder/:id is declared BEFORE GET /inbox/:id', () => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
assert
|
|
85
|
-
assert.ok(byId > -1, 'the by-id route is mounted');
|
|
86
|
-
assert.ok(trail < byId, `/inbox/by-builder/:id (${trail}) must precede /inbox/:id (${byId}) or :id swallows it`);
|
|
83
|
+
// Order OR a constrained param — either makes the trail route reachable. See
|
|
84
|
+
// assertCannotSwallow in tests/helpers.mjs (task 1003747).
|
|
85
|
+
assertCannotSwallow(assert, mountedPaths, '/inbox/by-builder/:id', '/inbox/:id');
|
|
87
86
|
});
|
|
88
87
|
|
|
89
88
|
// ---------------------------------------------------------------------------
|
package/tests/prose_edits.mjs
CHANGED
|
@@ -160,7 +160,12 @@ await test('ROUTE SHAPE: the triage-gated PATCH /inbox/:id was NOT widened to re
|
|
|
160
160
|
assert.ok(row, 'the triage route still exists');
|
|
161
161
|
assert.equal(row.rank, 'metic+archon', 'PATCH /inbox/:id must keep its triage-level gate');
|
|
162
162
|
const src = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
|
|
163
|
-
|
|
163
|
+
// Tolerate an inline param constraint on the path — `'/inbox/:id(\d+)'` (task
|
|
164
|
+
// 1003747, which added one so this route stops swallowing another module's
|
|
165
|
+
// `/inbox/rotting`). Matching the bare literal made this test report the triage
|
|
166
|
+
// route as missing when only its MATCHER had been narrowed. The route key above
|
|
167
|
+
// is canonicalised for the same reason (src/bongos/route-rank-check.js).
|
|
168
|
+
const start = src.search(/router\.patch\('\/inbox\/:id(?:\([^)]*\))?',/);
|
|
164
169
|
const body = src.slice(start, src.indexOf('router.', start + 20));
|
|
165
170
|
// Assert on the VALIDATION SCHEMA, not on any mention of the string: that route
|
|
166
171
|
// already discusses body_md in a comment (about it not being parseable as a
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/rank_floor_wiring.mjs — the rank floor is WIRED, not merely derivable
|
|
2
|
+
// (task 1003663, goal 1000111, ADR 0270).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS SEPARATELY FROM tests/rank_gate.mjs. That file pins the
|
|
5
|
+
// PURE helpers — `deriveRequiredRank` / `highestRank` — by handing them opts it
|
|
6
|
+
// builds itself. That is necessary and it is not sufficient: the whole defect
|
|
7
|
+
// ADR 0270 repairs was a derivation that was perfectly correct and never
|
|
8
|
+
// REACHED, because the input it needed (`touches[]`) stopped arriving at the
|
|
9
|
+
// call site. A test that constructs the inputs by hand cannot catch that class
|
|
10
|
+
// of bug a second time — it asserts the calculator, not the plumbing.
|
|
11
|
+
//
|
|
12
|
+
// So this file drives the real `createTask` against a SQL-aware client and reads
|
|
13
|
+
// the `requires_rank` actually bound into the INSERT, plus the two PATCH setters
|
|
14
|
+
// that can move a floor input after creation.
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/rank_floor_wiring.mjs
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { makeSqlAwareClient, lifecycleDbSource } from './helpers.mjs';
|
|
22
|
+
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { createTask } = require('../modules/lifecycle/db.js');
|
|
26
|
+
|
|
27
|
+
// The INSERT binds ~25 columns positionally, so a hardcoded params[20] would be a
|
|
28
|
+
// silent lie the first time somebody inserts a column. Read the column list out of
|
|
29
|
+
// the statement under test and resolve the index by NAME.
|
|
30
|
+
function boundValue(sql, params, column) {
|
|
31
|
+
const cols = String(sql).slice(sql.indexOf('(') + 1, sql.indexOf(')'))
|
|
32
|
+
.split(',').map((c) => c.trim());
|
|
33
|
+
const i = cols.indexOf(column);
|
|
34
|
+
assert.notEqual(i, -1, `INSERT INTO tasks no longer binds a '${column}' column — update this test`);
|
|
35
|
+
return params[i];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One create against a fake goal row; returns the requires_rank the INSERT bound.
|
|
39
|
+
async function createdFloor({ scopeModules, needsMigration = false, requiresRank = null }) {
|
|
40
|
+
let insert = null;
|
|
41
|
+
const { _client } = makeSqlAwareClient(async (sql, params) => {
|
|
42
|
+
if (/^SELECT scope_modules FROM goals/i.test(sql)) {
|
|
43
|
+
return { rows: [{ scope_modules: scopeModules }] };
|
|
44
|
+
}
|
|
45
|
+
if (/^INSERT INTO tasks/i.test(sql)) {
|
|
46
|
+
insert = { sql, params };
|
|
47
|
+
return { rows: [{ id: '9001' }] };
|
|
48
|
+
}
|
|
49
|
+
return { rows: [] };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await createTask({
|
|
53
|
+
versionId: 'BONGOS-V2',
|
|
54
|
+
title: 'a task',
|
|
55
|
+
goalId: '1000111',
|
|
56
|
+
needsMigration,
|
|
57
|
+
requiresRank,
|
|
58
|
+
}, { client: _client });
|
|
59
|
+
|
|
60
|
+
assert.ok(insert, 'createTask never reached its INSERT');
|
|
61
|
+
return boundValue(insert.sql, insert.params, 'requires_rank');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// createTask — the create path actually reads the goal wall
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
test('a goal whose wall reaches protected territory floors the task at metic', async () => {
|
|
69
|
+
// No touches[], not security_sensitive, not a migration — every pre-0270 input
|
|
70
|
+
// is absent, which is precisely the shape that used to come out 'xenos'.
|
|
71
|
+
assert.equal(await createdFloor({ scopeModules: ['hall-ui', 'kernel'] }), 'metic');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an unprotected wall leaves the task in the open queue', async () => {
|
|
75
|
+
assert.equal(await createdFloor({ scopeModules: ['hall-ui'] }), 'xenos');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('needs_migration floors at metic even under an unprotected wall', async () => {
|
|
79
|
+
assert.equal(await createdFloor({ scopeModules: ['hall-ui'], needsMigration: true }), 'metic');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('THE REGRESSION: the create path reads the wall, it does not just own a helper', async () => {
|
|
83
|
+
// Mutation guard. If someone drops the goalScopeProtected call from createTask
|
|
84
|
+
// — the exact way this bug happened the first time, by a caller ceasing to
|
|
85
|
+
// supply an input — the pure tests in rank_gate.mjs all still pass and this one
|
|
86
|
+
// fails. That asymmetry is the point of the file.
|
|
87
|
+
assert.equal(await createdFloor({ scopeModules: ['lifecycle'] }), 'metic');
|
|
88
|
+
assert.equal(await createdFloor({ scopeModules: ['government'] }), 'metic');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('an explicit requires_rank still only RAISES, never lowers the derived floor', async () => {
|
|
92
|
+
assert.equal(await createdFloor({ scopeModules: ['kernel'], requiresRank: 'xenos' }), 'metic');
|
|
93
|
+
assert.equal(await createdFloor({ scopeModules: ['hall-ui'], requiresRank: 'archon' }), 'archon');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a missing goal row is the honest false, not a throw', async () => {
|
|
97
|
+
// scope_modules is NOT NULL DEFAULT '{}', but a goal deleted mid-transaction
|
|
98
|
+
// returns no row at all. That must fall through to the other arms, not crash a
|
|
99
|
+
// create.
|
|
100
|
+
assert.equal(await createdFloor({ scopeModules: undefined }), 'xenos');
|
|
101
|
+
assert.equal(await createdFloor({ scopeModules: undefined, needsMigration: true }), 'metic');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// The PATCH setters — a floor INPUT may be flipped after creation
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// These two write through the module-level pool rather than an injectable client,
|
|
108
|
+
// so they are pinned at the source. The assertion is narrow on purpose: it is the
|
|
109
|
+
// RATCHET that matters (raise on true, never lower), not the surrounding SQL.
|
|
110
|
+
|
|
111
|
+
test('updateTaskNeedsMigration ratchets requires_rank UP, exactly like its security_sensitive sibling', () => {
|
|
112
|
+
const src = lifecycleDbSource();
|
|
113
|
+
const setter = src.slice(src.indexOf('async function updateTaskNeedsMigration'));
|
|
114
|
+
const body = setter.slice(0, setter.indexOf('\n}\n') + 1);
|
|
115
|
+
|
|
116
|
+
assert.match(
|
|
117
|
+
body.replace(/\s+/g, ' '),
|
|
118
|
+
/requires_rank = CASE WHEN \$1 AND requires_rank IN \('xenos', 'thetes'\) THEN 'metic' ELSE requires_rank END/,
|
|
119
|
+
'PATCHing needs_migration=true must raise the floor — otherwise ADR 0270 is closed on the create path and open on the PATCH path'
|
|
120
|
+
);
|
|
121
|
+
// Raise-only: nothing in the setter may write a rank BACK DOWN when the flag
|
|
122
|
+
// clears. An existing metic may be a protected-scope floor or an Archon override.
|
|
123
|
+
assert.doesNotMatch(body, /ELSE 'xenos'/, 'the setter must never lower an existing floor');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('both floor-input setters share one ratchet — neither may drift from the other', () => {
|
|
127
|
+
const src = lifecycleDbSource();
|
|
128
|
+
const ratchet = /requires_rank = CASE WHEN \$1 AND requires_rank IN \('xenos', 'thetes'\)\s+THEN 'metic' ELSE requires_rank END/g;
|
|
129
|
+
const hits = src.match(ratchet) || [];
|
|
130
|
+
assert.equal(
|
|
131
|
+
hits.length, 2,
|
|
132
|
+
`expected the ratchet in exactly 2 setters (security_sensitive + needs_migration), found ${hits.length} — ` +
|
|
133
|
+
'a new boolean that feeds deriveRequiredRank needs one too, and a removed one is a reopened gap'
|
|
134
|
+
);
|
|
135
|
+
});
|
package/tests/rank_gate.mjs
CHANGED
|
@@ -399,6 +399,51 @@ t('empty / null / undefined touches with no sensitivity => xenos (no throw)', ()
|
|
|
399
399
|
assert.equal(db.deriveRequiredRank(undefined, undefined), 'xenos');
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// task 1003663 / ADR 0270 — the two touches-FREE inputs. ADR 0049 retired
|
|
404
|
+
// predictive touches[], so at CREATE time the arms above see an empty array on
|
|
405
|
+
// most vectors and the floor never rose: 68 of 88 needs_migration tasks sat at
|
|
406
|
+
// xenos, one of them a ready task to rewrite the auth token store. These pin the
|
|
407
|
+
// replacement, which derives from facts a task carries WITHOUT a prediction.
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
console.log('\nderiveRequiredRank (the touches-free floors — task 1003663):');
|
|
410
|
+
|
|
411
|
+
t('needs_migration floors at metic with NO touches at all', () => {
|
|
412
|
+
assert.equal(db.deriveRequiredRank([], false, { needsMigration: true }), 'metic');
|
|
413
|
+
assert.equal(db.deriveRequiredRank(null, false, { needsMigration: true }), 'metic');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
t('a protected goal scope floors at metic with NO touches at all', () => {
|
|
417
|
+
assert.equal(db.deriveRequiredRank([], false, { scopeProtected: true }), 'metic');
|
|
418
|
+
assert.equal(db.deriveRequiredRank(null, false, { scopeProtected: true }), 'metic');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
t('THE REGRESSION: the exact shape of task 1003372 (ready, migration, no touches)', () => {
|
|
422
|
+
// Before ADR 0270 this returned 'xenos' — a Xenos could claim a task to hash
|
|
423
|
+
// the builder_sessions token store at rest. It must never do so again.
|
|
424
|
+
assert.equal(
|
|
425
|
+
db.deriveRequiredRank([], false, { needsMigration: true, scopeProtected: false }),
|
|
426
|
+
'metic'
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
t('the flags are opt-in: false / absent / non-boolean never RAISE the floor', () => {
|
|
431
|
+
// Only a strict === true raises, so a stray truthy value cannot quietly wall
|
|
432
|
+
// off the open queue (the fail-closed direction here is "stay open").
|
|
433
|
+
assert.equal(db.deriveRequiredRank([], false, {}), 'xenos');
|
|
434
|
+
assert.equal(db.deriveRequiredRank([], false, { needsMigration: false, scopeProtected: false }), 'xenos');
|
|
435
|
+
assert.equal(db.deriveRequiredRank([], false, { needsMigration: 'yes' }), 'xenos');
|
|
436
|
+
assert.equal(db.deriveRequiredRank([], false, { needsMigration: 1 }), 'xenos');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
t('omitting opts entirely is the pre-0270 two-argument behaviour, unchanged', () => {
|
|
440
|
+
// Every existing call site passes two arguments; none may change meaning.
|
|
441
|
+
assert.equal(db.deriveRequiredRank([], false), 'xenos');
|
|
442
|
+
assert.equal(db.deriveRequiredRank(['public/game/main.js'], false), 'xenos');
|
|
443
|
+
assert.equal(db.deriveRequiredRank(['migrations/145_x.sql'], false), 'metic');
|
|
444
|
+
assert.equal(db.deriveRequiredRank([], true), 'metic');
|
|
445
|
+
});
|
|
446
|
+
|
|
402
447
|
console.log('\nhighestRank (compose floor with an override; raise-only):');
|
|
403
448
|
|
|
404
449
|
t('picks the higher-authority rank either way', () => {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// tests/route_shadow_guard.mjs — the guard that keeps a `:param` route from
|
|
2
|
+
// swallowing a literal route mounted after it (task 1003747).
|
|
3
|
+
//
|
|
4
|
+
// GET /inbox/rotting answered 400 bad_id in production for releases: `ideas`
|
|
5
|
+
// mounts GET /inbox/:id before `lifecycle` mounts GET /inbox/rotting, so the param
|
|
6
|
+
// matched first and ended the request before the rot handler was reached. The
|
|
7
|
+
// whole 30-day rot cadence was dead, and the hall's only caller swallowed the
|
|
8
|
+
// error into an empty section that looked like "nothing is rotting".
|
|
9
|
+
//
|
|
10
|
+
// The guard is tested on SYNTHETIC mount sequences, not only the live tree: a scan
|
|
11
|
+
// that passes because the tree is clean today proves nothing about what it
|
|
12
|
+
// REFUSES. The live-tree case at the bottom is what proves the real fix holds.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/route_shadow_guard.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 {
|
|
21
|
+
checkNoRouteShadowing,
|
|
22
|
+
findShadowPairs,
|
|
23
|
+
mountSequence,
|
|
24
|
+
isBareParam,
|
|
25
|
+
isConstrainedParam,
|
|
26
|
+
} = require('../scripts/gds/route-shadow-guard.js');
|
|
27
|
+
|
|
28
|
+
let passed = 0;
|
|
29
|
+
let failed = 0;
|
|
30
|
+
function test(name, fn) {
|
|
31
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
32
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const r = (module_, file, method, route) => ({ module: module_, file, method, route });
|
|
36
|
+
|
|
37
|
+
// --- THE DEFECT, EXACTLY AS IT SHIPPED -------------------------------------
|
|
38
|
+
|
|
39
|
+
test('catches the real pair: GET /inbox/:id mounted before GET /inbox/rotting', () => {
|
|
40
|
+
const pairs = findShadowPairs([
|
|
41
|
+
r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
|
|
42
|
+
r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
|
|
43
|
+
]);
|
|
44
|
+
assert.equal(pairs.length, 1);
|
|
45
|
+
assert.equal(pairs[0].early.route, '/inbox/:id');
|
|
46
|
+
assert.equal(pairs[0].later.route, '/inbox/rotting');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('the check hard-fails on that sequence, and names the fall-through fix', () => {
|
|
50
|
+
const out = checkNoRouteShadowing({
|
|
51
|
+
sequence: [
|
|
52
|
+
r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
|
|
53
|
+
r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
assert.equal(out.ok, false);
|
|
57
|
+
assert.equal(out.hardFail, true);
|
|
58
|
+
assert.equal(out.violations.length, 1);
|
|
59
|
+
assert.match(out.violations[0], /\/inbox\/rotting/);
|
|
60
|
+
assert.match(out.violations[0], /never reaches its handler/);
|
|
61
|
+
assert.match(out.violations[0], /\\d\+/, 'points at constraining the param');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// --- WHAT THE FIX DOES -----------------------------------------------------
|
|
65
|
+
|
|
66
|
+
test('a CONSTRAINED param shadows nothing — this is what the fix relies on', () => {
|
|
67
|
+
const pairs = findShadowPairs([
|
|
68
|
+
r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id(\\d+)'),
|
|
69
|
+
r('lifecycle', 'modules/lifecycle/routes/rot.js', 'get', '/inbox/rotting'),
|
|
70
|
+
]);
|
|
71
|
+
assert.deepEqual(pairs, []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('order matters: a literal mounted BEFORE the param is fine', () => {
|
|
75
|
+
const pairs = findShadowPairs([
|
|
76
|
+
r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/awaiting-nod'),
|
|
77
|
+
r('ideas', 'modules/ideas/routes/inbox.js', 'get', '/inbox/:id'),
|
|
78
|
+
]);
|
|
79
|
+
assert.deepEqual(pairs, [], 'declaring literals first is the in-file convention and stays legal');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- WHAT MUST NOT BE FLAGGED ---------------------------------------------
|
|
83
|
+
|
|
84
|
+
test('different methods never compete', () => {
|
|
85
|
+
assert.deepEqual(findShadowPairs([
|
|
86
|
+
r('a', 'a.js', 'get', '/inbox/:id'),
|
|
87
|
+
r('b', 'b.js', 'patch', '/inbox/rotting'),
|
|
88
|
+
]), []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('different depths never compete', () => {
|
|
92
|
+
assert.deepEqual(findShadowPairs([
|
|
93
|
+
r('a', 'a.js', 'get', '/inbox/:id'),
|
|
94
|
+
r('b', 'b.js', 'get', '/inbox/rotting/detail'),
|
|
95
|
+
]), []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('two params at the same position are one shape, not a shadow', () => {
|
|
99
|
+
assert.deepEqual(findShadowPairs([
|
|
100
|
+
r('a', 'a.js', 'get', '/inbox/:id'),
|
|
101
|
+
r('b', 'b.js', 'get', '/inbox/:ideaId'),
|
|
102
|
+
]), []);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a differing earlier literal segment means the routes never overlap', () => {
|
|
106
|
+
assert.deepEqual(findShadowPairs([
|
|
107
|
+
r('a', 'a.js', 'get', '/inbox/:id'),
|
|
108
|
+
r('b', 'b.js', 'get', '/blockers/rotting'),
|
|
109
|
+
]), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the param must sit WHERE the literal is', () => {
|
|
113
|
+
assert.deepEqual(findShadowPairs([
|
|
114
|
+
r('a', 'a.js', 'get', '/tasks/:id/water'),
|
|
115
|
+
r('b', 'b.js', 'get', '/tasks/rotting/water'),
|
|
116
|
+
]).length, 1, 'same position, so it does shadow');
|
|
117
|
+
assert.deepEqual(findShadowPairs([
|
|
118
|
+
r('a', 'a.js', 'get', '/tasks/:id/water'),
|
|
119
|
+
r('b', 'b.js', 'get', '/tasks/:id/rotting'),
|
|
120
|
+
]), [], 'literal in a position the param does not occupy');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- SEGMENT CLASSIFIERS ---------------------------------------------------
|
|
124
|
+
|
|
125
|
+
test('bare vs constrained param', () => {
|
|
126
|
+
assert.equal(isBareParam(':id'), true);
|
|
127
|
+
assert.equal(isBareParam(':id(\\d+)'), false);
|
|
128
|
+
assert.equal(isBareParam('rotting'), false);
|
|
129
|
+
assert.equal(isConstrainedParam(':id(\\d+)'), true);
|
|
130
|
+
assert.equal(isConstrainedParam(':id'), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- SCAN INTEGRITY --------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
test('an empty enumeration is a scan DEFECT, never a clean pass', () => {
|
|
136
|
+
const out = checkNoRouteShadowing({ sequence: [] });
|
|
137
|
+
assert.equal(out.ok, false);
|
|
138
|
+
assert.equal(out.hardFail, true);
|
|
139
|
+
assert.match(out.violations[0], /scan defect/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// --- THE LIVE TREE ---------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
test('the real repo enumerates routes and has no shadowing pair', () => {
|
|
145
|
+
const seq = mountSequence();
|
|
146
|
+
assert.ok(seq.length > 100, `expected a real mount sequence, got ${seq.length}`);
|
|
147
|
+
const out = checkNoRouteShadowing();
|
|
148
|
+
assert.equal(out.hardFail, false, `live tree has shadowing pairs:\n${out.violations.join('\n')}`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('the live tree still declares the rot feed and the constrained idea read', () => {
|
|
152
|
+
const seq = mountSequence();
|
|
153
|
+
const rot = seq.find((x) => x.route === '/inbox/rotting' && x.method === 'get');
|
|
154
|
+
assert.ok(rot, 'GET /inbox/rotting must still be declared');
|
|
155
|
+
const idea = seq.find((x) => x.route.startsWith('/inbox/:id') && x.method === 'get');
|
|
156
|
+
assert.ok(idea, 'GET /inbox/:id must still be declared');
|
|
157
|
+
assert.ok(isConstrainedParam(idea.route.split('/').filter(Boolean)[1]),
|
|
158
|
+
`GET ${idea.route} must constrain its id, or it eats /inbox/rotting again`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// --- THE BEHAVIOUR THE FIX DEPENDS ON -------------------------------------
|
|
162
|
+
//
|
|
163
|
+
// The static guard above proves the DECLARATION is constrained. This proves the
|
|
164
|
+
// constraint actually routes that way in the Express version we ship — and it is
|
|
165
|
+
// not a formality: Express 5 REMOVED inline param regexes, so an upgrade would
|
|
166
|
+
// silently turn `:id(\d+)` back into a path that eats /inbox/rotting, with the
|
|
167
|
+
// static check still passing because the declaration text is unchanged. This is
|
|
168
|
+
// the test that goes red on that upgrade.
|
|
169
|
+
|
|
170
|
+
async function behaviour() {
|
|
171
|
+
const express = require('express');
|
|
172
|
+
const http = require('node:http');
|
|
173
|
+
const app = express();
|
|
174
|
+
|
|
175
|
+
// Mounted in the SAME order the real loader mounts them: `ideas` (the param
|
|
176
|
+
// route) before `lifecycle` (the literal).
|
|
177
|
+
const ideas = express.Router();
|
|
178
|
+
ideas.get('/inbox/:id(\\d+)', (req, res) => res.json({ handler: 'idea', id: req.params.id }));
|
|
179
|
+
const lifecycle = express.Router();
|
|
180
|
+
lifecycle.get('/inbox/rotting', (_req, res) => res.json({ handler: 'rot' }));
|
|
181
|
+
app.use(ideas);
|
|
182
|
+
app.use(lifecycle);
|
|
183
|
+
|
|
184
|
+
const server = http.createServer(app);
|
|
185
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
186
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
187
|
+
try {
|
|
188
|
+
const rot = await fetch(`${base}/inbox/rotting`);
|
|
189
|
+
const rotBody = await rot.json();
|
|
190
|
+
test('GET /inbox/rotting reaches the ROT handler despite being mounted second', () => {
|
|
191
|
+
assert.equal(rot.status, 200);
|
|
192
|
+
assert.equal(rotBody.handler, 'rot');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const idea = await fetch(`${base}/inbox/1234`);
|
|
196
|
+
const ideaBody = await idea.json();
|
|
197
|
+
test('a numeric id still reaches the idea handler', () => {
|
|
198
|
+
assert.equal(idea.status, 200);
|
|
199
|
+
assert.equal(ideaBody.handler, 'idea');
|
|
200
|
+
assert.equal(ideaBody.id, '1234');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const junk = await fetch(`${base}/inbox/not-a-number`);
|
|
204
|
+
test('an unmatched non-numeric segment 404s instead of 400 — the accepted trade-off', () => {
|
|
205
|
+
assert.equal(junk.status, 404);
|
|
206
|
+
});
|
|
207
|
+
} finally {
|
|
208
|
+
await new Promise((resolve) => server.close(resolve));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await behaviour();
|
|
213
|
+
|
|
214
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
215
|
+
if (failed > 0) process.exit(1);
|