@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
|
@@ -23,6 +23,31 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
|
23
23
|
// -------------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
// goalScopeProtected — does this task's owning goal reach protected territory
|
|
27
|
+
// (the permission / ship-grade / deploy / migration core)? The touches-free half
|
|
28
|
+
// of the create-time rank floor (task 1003663, ADR 0270).
|
|
29
|
+
//
|
|
30
|
+
// It reads the goal's `scope_modules` wall through the SAME published primitive
|
|
31
|
+
// modules/ideas/inbox.js already uses for routed filings (task 1003066, BV1.R05)
|
|
32
|
+
// and that the pre-push hook, the grader and main-audit read — so this floor
|
|
33
|
+
// cannot drift from the roster they enforce.
|
|
34
|
+
//
|
|
35
|
+
// Runs on the CALLER'S client when there is one, so it sees the same
|
|
36
|
+
// transaction snapshot as the INSERT it feeds — a goal rescoped mid-transaction
|
|
37
|
+
// cannot land between the check and the row. FAILS CLOSED-ish by omission: an
|
|
38
|
+
// absent goal or a non-array wall yields false, which is the honest answer
|
|
39
|
+
// ("no protected scope declared"), and the other three arms of deriveRequiredRank
|
|
40
|
+
// still apply.
|
|
41
|
+
async function goalScopeProtected(goalId, client) {
|
|
42
|
+
if (goalId == null) return false;
|
|
43
|
+
const { rows } = await (client || pool).query(
|
|
44
|
+
'SELECT scope_modules FROM goals WHERE id = $1',
|
|
45
|
+
[goalId]
|
|
46
|
+
);
|
|
47
|
+
const scope = rows.length ? rows[0].scope_modules : null;
|
|
48
|
+
return Array.isArray(scope) && api.moduleScopeMap.scopeIncludesProtected(scope);
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
// The version statuses that can still receive new work. 'shipped' and 'frozen' are
|
|
27
52
|
// closed: a version whose scope is sealed cannot be told about a bug found afterwards.
|
|
28
53
|
// Mirrors the CHECK constraint on versions.status (migrations/003_pms.sql).
|
|
@@ -124,15 +149,6 @@ async function createTask({
|
|
|
124
149
|
// to name a goal instead.
|
|
125
150
|
allowCatchAll = false,
|
|
126
151
|
}, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
|
|
127
|
-
// task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
|
|
128
|
-
// from touches + sensitivity, then let an explicit requiresRank override only
|
|
129
|
-
// UPWARD (highestRank) — a requested rank below the derived floor is clamped
|
|
130
|
-
// up to the floor (fail-closed; the floor mirrors the trust-boundary paths a
|
|
131
|
-
// sub-Metic builder cannot push to anyway).
|
|
132
|
-
const requiresRankEffective = highestRank(
|
|
133
|
-
deriveRequiredRank(touches, securitySensitive),
|
|
134
|
-
requiresRank ?? 'xenos'
|
|
135
|
-
);
|
|
136
152
|
// task 1677 / ADR 0096 (2026-06-28): creation is now the PRIMARY reward-assignment
|
|
137
153
|
// point — every task is born with a fair drachmae value, not just at promote/claim.
|
|
138
154
|
// When the caller supplies NO positive credits_reward (null/undefined/0), auto-assign
|
|
@@ -181,6 +197,26 @@ async function createTask({
|
|
|
181
197
|
}
|
|
182
198
|
goalIdEffective = await generalGoalIdForVersion(versionId);
|
|
183
199
|
}
|
|
200
|
+
// task 1498 / ADR 0084: every task carries a deliberate rank floor. Derive it,
|
|
201
|
+
// then let an explicit requiresRank override only UPWARD (highestRank) — a
|
|
202
|
+
// requested rank below the derived floor is clamped up to the floor
|
|
203
|
+
// (fail-closed; the floor mirrors the trust-boundary paths a sub-Metic builder
|
|
204
|
+
// cannot push to anyway).
|
|
205
|
+
//
|
|
206
|
+
// task 1003663 / ADR 0270: derived HERE, at the lowest create layer and AFTER
|
|
207
|
+
// goalIdEffective resolves, rather than in each route — the same argument the
|
|
208
|
+
// reward auto-assignment above makes. Every create vector (POST /tasks,
|
|
209
|
+
// POST /goals/:id/tasks, idea-promotion, capture, newcomer-restock, copy-desk,
|
|
210
|
+
// kickoff-seed, seed scripts) inherits the floor, and a vector added next year
|
|
211
|
+
// cannot forget it. Patching the two authoring routes instead is how
|
|
212
|
+
// POST /tasks and POST /goals/:id/tasks came to disagree in the first place.
|
|
213
|
+
const requiresRankEffective = highestRank(
|
|
214
|
+
deriveRequiredRank(touches, securitySensitive, {
|
|
215
|
+
needsMigration,
|
|
216
|
+
scopeProtected: await goalScopeProtected(goalIdEffective, deps.client),
|
|
217
|
+
}),
|
|
218
|
+
requiresRank ?? 'xenos'
|
|
219
|
+
);
|
|
184
220
|
const { rows } = await (deps.client || pool).query(
|
|
185
221
|
`INSERT INTO tasks (version_id, title, description, status, touches,
|
|
186
222
|
est_minutes, est_cost_usd, manual_degree, priority,
|
|
@@ -366,7 +402,7 @@ async function updateTaskNewcomerFriendly(id, newcomerFriendly) { return updateT
|
|
|
366
402
|
// (the === true coercion below preserves the boolean-only contract).
|
|
367
403
|
|
|
368
404
|
async function updateTaskSecuritySensitive(id, securitySensitive) {
|
|
369
|
-
// task 1498 / ADR
|
|
405
|
+
// task 1498 / ADR 0084: flagging a task security_sensitive RAISES its rank
|
|
370
406
|
// floor (a now-sensitive task must require Metic+). Recompute and ratchet
|
|
371
407
|
// requires_rank UP in the same atomic UPDATE so the two columns can't diverge;
|
|
372
408
|
// never lower it (an existing 'metic'/'archon' may be a protected-touch floor
|
|
@@ -386,7 +422,7 @@ async function updateTaskSecuritySensitive(id, securitySensitive) {
|
|
|
386
422
|
return rows[0] ?? null;
|
|
387
423
|
}
|
|
388
424
|
|
|
389
|
-
// task 1498 / ADR
|
|
425
|
+
// task 1498 / ADR 0084: set a task's rank floor explicitly (the Archon override).
|
|
390
426
|
// The DERIVED floor (touches + security_sensitive) is a HARD minimum — a request
|
|
391
427
|
// below it is clamped UP via highestRank, so an override can only RAISE the bar,
|
|
392
428
|
// never open a sensitive task to a lower rank. `requiresRank` is one live rank
|
|
@@ -396,7 +432,15 @@ async function updateTaskSecuritySensitive(id, securitySensitive) {
|
|
|
396
432
|
async function updateTaskRequiresRank(id, requiresRank) {
|
|
397
433
|
const task = await getTask(id);
|
|
398
434
|
if (!task) return null;
|
|
399
|
-
|
|
435
|
+
// task 1003663 / ADR 0270: the override re-derives with the SAME four inputs as
|
|
436
|
+
// creation. Without needsMigration/scopeProtected here, an Archon setting a
|
|
437
|
+
// migration task to 'xenos' would succeed — the floor would compute 'xenos'
|
|
438
|
+
// from an empty touches[] and clamp nothing, silently undoing the create-time
|
|
439
|
+
// protection. An override may still only RAISE.
|
|
440
|
+
const floor = deriveRequiredRank(task.touches, task.security_sensitive, {
|
|
441
|
+
needsMigration: task.needs_migration === true,
|
|
442
|
+
scopeProtected: await goalScopeProtected(task.goal_id, null),
|
|
443
|
+
});
|
|
400
444
|
const effective = highestRank(floor, requiresRank ?? 'xenos');
|
|
401
445
|
const { rows } = await pool.query(
|
|
402
446
|
`UPDATE tasks SET requires_rank = $1, updated_at = now() WHERE id = $2 RETURNING *`,
|
|
@@ -508,7 +552,32 @@ async function updateTaskAutomationTag(id, automationTag) { return updateTaskCol
|
|
|
508
552
|
// task 1002648: correct the create-time migration heuristic's verdict — a false
|
|
509
553
|
// flag makes claiming reserve a migration number that goes unused (task 1002614
|
|
510
554
|
// burned 213 that way). Boolean only; the route validates.
|
|
511
|
-
|
|
555
|
+
//
|
|
556
|
+
// task 1003663 / ADR 0270: needs_migration is now a rank-floor INPUT, so this
|
|
557
|
+
// stopped being a bare column write and became the exact sibling of
|
|
558
|
+
// updateTaskSecuritySensitive above — same CASE, same ratchet, same reasoning.
|
|
559
|
+
// The correction this setter exists for runs BOTH ways: task 1002648 names the
|
|
560
|
+
// true→false direction, but false→true is the one that matters here, and without
|
|
561
|
+
// the ratchet a Metic could PATCH needs_migration onto an existing xenos task and
|
|
562
|
+
// leave the floor at xenos — reopening on the PATCH path exactly the gap ADR 0270
|
|
563
|
+
// closes on the create path. RAISES ONLY: flipping the flag back off never drops
|
|
564
|
+
// the gate, because an existing 'metic'/'archon' may be a protected-scope floor or
|
|
565
|
+
// a deliberate override. An Archon lowers it explicitly via updateTaskRequiresRank,
|
|
566
|
+
// which re-derives from all four inputs.
|
|
567
|
+
async function updateTaskNeedsMigration(id, needsMigration) {
|
|
568
|
+
const mig = needsMigration === true;
|
|
569
|
+
const { rows } = await pool.query(
|
|
570
|
+
`UPDATE tasks
|
|
571
|
+
SET needs_migration = $1,
|
|
572
|
+
requires_rank = CASE WHEN $1 AND requires_rank IN ('xenos', 'thetes')
|
|
573
|
+
THEN 'metic' ELSE requires_rank END,
|
|
574
|
+
updated_at = now()
|
|
575
|
+
WHERE id = $2
|
|
576
|
+
RETURNING *`,
|
|
577
|
+
[mig, id]
|
|
578
|
+
);
|
|
579
|
+
return rows[0] ?? null;
|
|
580
|
+
}
|
|
512
581
|
|
|
513
582
|
// BV1.R53 (task 1422): assign (or clear) the module_key that declares which
|
|
514
583
|
// module this task's work belongs to. Drives module-disjointness at claim time
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.627",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.627",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.627",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1389,6 +1389,7 @@ const CHECKS = [
|
|
|
1389
1389
|
require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
|
|
1390
1390
|
require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
|
|
1391
1391
|
require('./client-baseurl-guard.js').checkClientBaseUrl,
|
|
1392
|
+
require('./route-shadow-guard.js').checkNoRouteShadowing, // Check 31 — task 1003747: a `:param` route must not swallow a literal route mounted after it (cross-module mount order; the rot feed shipped dead this way). Rationale in that file.
|
|
1392
1393
|
require('./test-path-guard.js').checkTestsUseFileUrlToPath, // Check 30 — task 1002889: a test builds its repo root with fileURLToPath, never URL.pathname (a %20 or a Windows drive slash makes require() throw at load; CI's spaceless checkout can't see it)
|
|
1393
1394
|
// Check 29 — task 1003275 / ADR 0189: a work category ORIENTS a human and steers
|
|
1394
1395
|
// Claude; it authorises nothing. The owner said so twice, and the goal's own prose
|
|
@@ -34,6 +34,12 @@ const path = require('node:path');
|
|
|
34
34
|
|
|
35
35
|
const { resolveCoreRoot, resolveDocsRoot } = require('../../src/instance-config');
|
|
36
36
|
const rrc = require('../../src/bongos/route-rank-check');
|
|
37
|
+
// Destructured, not reached through `rrc.` — knip tracks a destructured CJS require but
|
|
38
|
+
// not namespace member access, so `rrc.canonicalRoutePath(...)` read as an unused export
|
|
39
|
+
// and tripped the dead-code ratchet (knip_issue_count 224 > baseline 223) even though the
|
|
40
|
+
// call site below is real. The named import is how a new helper on this module gets
|
|
41
|
+
// counted as used (task 1003747).
|
|
42
|
+
const { canonicalRoutePath } = require('../../src/bongos/route-rank-check');
|
|
37
43
|
const { API_PREFIX, API_VERSION, VERSIONED_API_PREFIX } = require('../../src/bongos/api-prefix');
|
|
38
44
|
// Task 2050 (ADR 0118): the enriched extractor resolves named constants used in
|
|
39
45
|
// validate() rules. Both requires are pure (no side effects / no DB / no express):
|
|
@@ -122,10 +128,27 @@ function commentBlockAbove(lines, i) {
|
|
|
122
128
|
return neutralizeTaskRefs(text);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
//
|
|
131
|
+
// An Express inline param constraint — `:id(\d+)` (task 1003747). It is a ROUTING
|
|
132
|
+
// detail, never part of the URL a caller types, so it is stripped everywhere a
|
|
133
|
+
// route string reaches a reader or the spec. Without this, `/inbox/:id(\d+)`
|
|
134
|
+
// generated the path template `/inbox/{id}(\\d+)` (malformed — it would break the
|
|
135
|
+
// generated client), renamed the operationId `get_inbox_id` → `get_inbox_id_d`
|
|
136
|
+
// (a silent breaking change for every client consumer), and printed the regex in
|
|
137
|
+
// the public API reference.
|
|
138
|
+
// The canonicaliser itself lives in route-rank-check, next to scanDeclarations — the
|
|
139
|
+
// scanner this generator already shares with the auditor — so there is ONE definition
|
|
140
|
+
// rather than a copy per consumer. Declarations therefore arrive canonical already;
|
|
141
|
+
// this wrapper stays because pathParams/routeForDisplay are pure exported helpers that
|
|
142
|
+
// callers (and tests/api_docs.mjs) hand raw strings to directly.
|
|
143
|
+
function routeForDisplay(route) {
|
|
144
|
+
return canonicalRoutePath(String(route || ''));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// :param → {param}; also return the ordered param name list. Inline constraints
|
|
148
|
+
// are dropped first so the template stays a valid OpenAPI path.
|
|
126
149
|
function pathParams(route) {
|
|
127
150
|
const names = [];
|
|
128
|
-
const openapiPath = route.replace(/:([A-Za-z0-9_]+)/g, (_, n) => {
|
|
151
|
+
const openapiPath = routeForDisplay(route).replace(/:([A-Za-z0-9_]+)/g, (_, n) => {
|
|
129
152
|
names.push(n);
|
|
130
153
|
return `{${n}}`;
|
|
131
154
|
});
|
|
@@ -732,7 +755,7 @@ function buildOperation(r, stats, schemas) {
|
|
|
732
755
|
const op = {
|
|
733
756
|
operationId: operationId(r.method, r.openapiPath),
|
|
734
757
|
tags: [r.tag],
|
|
735
|
-
summary: `${r.method} ${r.route}`,
|
|
758
|
+
summary: `${r.method} ${routeForDisplay(r.route)}`,
|
|
736
759
|
description: (r.description ? r.description + '\n\n' : '')
|
|
737
760
|
+ `**Rank:** \`${r.rank}\` — ${RANK_BLURB[r.rank] || RANK_BLURB.unknown}`
|
|
738
761
|
+ (r.perms && r.perms.length ? `\n\n**Permissions:** \`${r.perms.join('`, `')}\` (all required).` : ''),
|
|
@@ -908,7 +931,7 @@ function buildReferenceMd(model, spec) {
|
|
|
908
931
|
? (r.body.fields.length ? '`' + r.body.fields.join('`, `') + '`' : 'validated')
|
|
909
932
|
: '_undocumented_')
|
|
910
933
|
: '—';
|
|
911
|
-
L.push(`| ${r.method} | \`${API_PREFIX}${r.route}\` | \`${r.rank}\` | ${body} | ${shortDesc(r.description)} |`);
|
|
934
|
+
L.push(`| ${r.method} | \`${API_PREFIX}${routeForDisplay(r.route)}\` | \`${r.rank}\` | ${body} | ${shortDesc(r.description)} |`);
|
|
912
935
|
}
|
|
913
936
|
L.push('');
|
|
914
937
|
}
|
|
@@ -1053,6 +1076,7 @@ module.exports = {
|
|
|
1053
1076
|
validateOpenapi,
|
|
1054
1077
|
commentBlockAbove,
|
|
1055
1078
|
pathParams,
|
|
1079
|
+
routeForDisplay,
|
|
1056
1080
|
captureBalanced,
|
|
1057
1081
|
topLevelKeys,
|
|
1058
1082
|
topLevelEntries,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/route-shadow-guard.js — a `:param` route must not swallow a LITERAL
|
|
3
|
+
// route mounted after it (task 1003747).
|
|
4
|
+
//
|
|
5
|
+
// THE BUG THIS EXISTS TO STOP. `GET /inbox/rotting` — the rot feed behind the
|
|
6
|
+
// hall's "Going quiet" section (modules/lifecycle/routes/rot.js) — answered
|
|
7
|
+
// `400 bad_id` in production for releases. Nothing was wrong with the handler: it
|
|
8
|
+
// was never reached. `mountModuleRoutes` (src/module-loader/loader.js) mounts each
|
|
9
|
+
// enabled module's route factories in DISCOVERY order, so `ideas` mounted its
|
|
10
|
+
// `GET /inbox/:id` before `lifecycle` mounted `GET /inbox/rotting`; Express matched
|
|
11
|
+
// the param route first, failed to parse "rotting" as an id, and ended the request.
|
|
12
|
+
// The entire 30-day rot cadence (ADR 0232 — "work must not decay in silence") was
|
|
13
|
+
// dead, and its only caller swallowed the error, so the section rendered empty and
|
|
14
|
+
// looked healthy.
|
|
15
|
+
//
|
|
16
|
+
// WHY A FITNESS CHECK AND NOT A TEST. The collision is a property of the MOUNT
|
|
17
|
+
// ORDER ACROSS MODULES, which no single module's tests can see — each module's
|
|
18
|
+
// routes are correct in isolation, and modules/ideas/CLAUDE.md's "declare literals
|
|
19
|
+
// before :id" convention only governs collisions inside one file. The defect only
|
|
20
|
+
// exists in the assembled router, and it presents as a plausible 400 rather than a
|
|
21
|
+
// crash, so nothing goes red. A static scan at the assembly altitude is the only
|
|
22
|
+
// place this is visible before a user reports an empty page.
|
|
23
|
+
//
|
|
24
|
+
// WHAT COUNTS AS SHADOWING. Same HTTP method, same segment count, every segment
|
|
25
|
+
// equal up to one position where the earlier route has a `:param` and the later
|
|
26
|
+
// route has a literal. Same-arity is required because Express only matches a
|
|
27
|
+
// pattern against a path of equal depth (no wildcards are in play here), and the
|
|
28
|
+
// param must sit where the literal is or the two never compete.
|
|
29
|
+
//
|
|
30
|
+
// THE FIX IT POINTS AT. Constrain the param so a non-numeric segment falls
|
|
31
|
+
// THROUGH — `router.get('/inbox/:id(\\d+)', …)` — which works regardless of mount
|
|
32
|
+
// order. Reordering declarations is NOT a general fix: it cannot reach a route
|
|
33
|
+
// another module contributes.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('node:fs');
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
|
|
40
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
41
|
+
const NAME = 'no `:param` route shadows a literal route mounted after it (task 1003747)';
|
|
42
|
+
|
|
43
|
+
// Route declarations, as written. Matches the `router.<method>('<path>'` form every
|
|
44
|
+
// route factory in this repo uses.
|
|
45
|
+
const ROUTE_DECL = /router\.(get|post|patch|put|delete)\(\s*['"`]([^'"`]+)['"`]/g;
|
|
46
|
+
|
|
47
|
+
// A param segment already constrained by an inline Express pattern — `:id(\d+)` —
|
|
48
|
+
// cannot match a non-matching literal, so it shadows nothing. Recognising this is
|
|
49
|
+
// what makes the check pass once the fix is applied rather than demanding a
|
|
50
|
+
// reorder it cannot verify.
|
|
51
|
+
function isConstrainedParam(seg) {
|
|
52
|
+
return seg.startsWith(':') && seg.includes('(');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isBareParam(seg) {
|
|
56
|
+
return seg.startsWith(':') && !seg.includes('(');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The mount sequence: for each module with a module.json, its contributes.routes in
|
|
60
|
+
// declared order, and within each file its route declarations in source order. This
|
|
61
|
+
// mirrors mountModuleRoutes' iteration — modules in readdir order, route keys in
|
|
62
|
+
// manifest order — so the sequence this returns is the order Express sees.
|
|
63
|
+
function mountSequence({ modulesDir, readdir, readFile } = {}) {
|
|
64
|
+
const md = modulesDir || path.join(REPO_ROOT, 'modules');
|
|
65
|
+
const rd = readdir || ((d) => { try { return fs.readdirSync(d); } catch { return []; } });
|
|
66
|
+
const read = readFile || ((f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } });
|
|
67
|
+
const seq = [];
|
|
68
|
+
for (const key of rd(md)) {
|
|
69
|
+
const manRaw = read(path.join(md, key, 'module.json'));
|
|
70
|
+
if (manRaw == null) continue;
|
|
71
|
+
let man;
|
|
72
|
+
try { man = JSON.parse(manRaw); } catch { continue; }
|
|
73
|
+
for (const rk of ((man.contributes || {}).routes) || []) {
|
|
74
|
+
const rel = path.join('modules', key, 'routes', `${rk}.js`);
|
|
75
|
+
const src = read(path.join(md, key, 'routes', `${rk}.js`));
|
|
76
|
+
if (src == null) continue;
|
|
77
|
+
ROUTE_DECL.lastIndex = 0;
|
|
78
|
+
let m;
|
|
79
|
+
while ((m = ROUTE_DECL.exec(src))) {
|
|
80
|
+
seq.push({ module: key, file: rel, method: m[1], route: m[2] });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return seq;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const segments = (p) => p.split('/').filter(Boolean);
|
|
88
|
+
|
|
89
|
+
// findShadowPairs(seq) — every (param route, later literal route) pair where the
|
|
90
|
+
// param route wins the match. Pure + exported so tests pin the rule without a
|
|
91
|
+
// filesystem.
|
|
92
|
+
function findShadowPairs(seq) {
|
|
93
|
+
const pairs = [];
|
|
94
|
+
for (let i = 0; i < seq.length; i += 1) {
|
|
95
|
+
const early = seq[i];
|
|
96
|
+
const a = segments(early.route);
|
|
97
|
+
if (!a.some(isBareParam)) continue;
|
|
98
|
+
for (let j = i + 1; j < seq.length; j += 1) {
|
|
99
|
+
const later = seq[j];
|
|
100
|
+
const b = segments(later.route);
|
|
101
|
+
if (early.method !== later.method || a.length !== b.length) continue;
|
|
102
|
+
let shadows = true;
|
|
103
|
+
let viaParam = false;
|
|
104
|
+
for (let k = 0; k < a.length; k += 1) {
|
|
105
|
+
if (isBareParam(a[k])) {
|
|
106
|
+
// A param only eats a LITERAL. Two params at the same position are the
|
|
107
|
+
// same route shape, not a shadowing pair.
|
|
108
|
+
if (b[k].startsWith(':')) { shadows = false; break; }
|
|
109
|
+
viaParam = true;
|
|
110
|
+
} else if (isConstrainedParam(a[k])) {
|
|
111
|
+
shadows = false; // constrained: it cannot match an arbitrary literal
|
|
112
|
+
break;
|
|
113
|
+
} else if (a[k] !== b[k]) {
|
|
114
|
+
shadows = false;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (shadows && viaParam) pairs.push({ early, later });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return pairs;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function checkNoRouteShadowing(opts = {}) {
|
|
125
|
+
const seq = opts.sequence || mountSequence(opts);
|
|
126
|
+
if (!seq.length) {
|
|
127
|
+
return {
|
|
128
|
+
name: NAME,
|
|
129
|
+
ok: false,
|
|
130
|
+
hardFail: true,
|
|
131
|
+
violations: ['scan defect — enumerated 0 route declarations across modules/*/routes; '
|
|
132
|
+
+ 'a broken enumeration, not a clean result.'],
|
|
133
|
+
warnings: [],
|
|
134
|
+
note: 'static scan of the cross-module route mount order.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const pairs = findShadowPairs(seq);
|
|
138
|
+
const violations = pairs.map(({ early, later }) => (
|
|
139
|
+
`${early.method.toUpperCase()} ${early.route} (${early.file}) is mounted BEFORE `
|
|
140
|
+
+ `${later.method.toUpperCase()} ${later.route} (${later.file}) and swallows it — `
|
|
141
|
+
+ `a request for ${later.route} never reaches its handler. `
|
|
142
|
+
+ `Constrain the param so a non-matching segment falls through `
|
|
143
|
+
+ `(e.g. '${early.route.replace(/:(\w+)(?![\w(])/, ':$1(\\\\d+)')}'); reordering cannot fix a `
|
|
144
|
+
+ 'cross-module pair, because mount order follows module discovery.'
|
|
145
|
+
));
|
|
146
|
+
return {
|
|
147
|
+
name: NAME,
|
|
148
|
+
ok: violations.length === 0,
|
|
149
|
+
hardFail: violations.length > 0,
|
|
150
|
+
violations,
|
|
151
|
+
warnings: [],
|
|
152
|
+
note: `${seq.length} route declaration(s) scanned in mount order: no bare \`:param\` route may `
|
|
153
|
+
+ 'precede a literal route of the same method and depth. The rot feed shipped dead for releases '
|
|
154
|
+
+ 'this way (GET /inbox/:id ate GET /inbox/rotting across a module boundary) and presented as a '
|
|
155
|
+
+ 'plausible 400, so no test went red.',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function main() {
|
|
160
|
+
const r = checkNoRouteShadowing();
|
|
161
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
162
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
163
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (require.main === module) main();
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
checkNoRouteShadowing,
|
|
170
|
+
findShadowPairs,
|
|
171
|
+
mountSequence,
|
|
172
|
+
isBareParam,
|
|
173
|
+
isConstrainedParam,
|
|
174
|
+
ROUTE_DECL,
|
|
175
|
+
};
|
|
@@ -630,11 +630,36 @@ const ROUTE_PATH_LINE_RE = /^\s*['"]([^'"]+)['"]\s*,/;
|
|
|
630
630
|
// in every real case; the slack only tolerates an interleaved comment.
|
|
631
631
|
const PATH_LOOKAHEAD = 5;
|
|
632
632
|
|
|
633
|
+
// An Express inline param constraint — the `(\d+)` in `/inbox/:id(\d+)`. It constrains
|
|
634
|
+
// MATCHING; it is not part of the path a caller types, so every consumer of "the route
|
|
635
|
+
// path" must see the canonical form (task 1003747).
|
|
636
|
+
//
|
|
637
|
+
// WHY THIS LIVES HERE. scanDeclarations is the single scanner the auditor, the published
|
|
638
|
+
// OpenAPI contract and a dozen route-order tests all read (see its comment below). When
|
|
639
|
+
// `GET /inbox/:id` gained a `(\d+)` — to stop it swallowing another module's
|
|
640
|
+
// `GET /inbox/rotting` — the raw string leaked into all of them at once: the OpenAPI path
|
|
641
|
+
// template became the malformed `/inbox/{id}(\\d+)`, the operationId silently renamed
|
|
642
|
+
// `get_inbox_id` → `get_inbox_id_d` (a breaking change for every generated-client
|
|
643
|
+
// consumer), and FIVE test files that look up `/inbox/:id` by exact string stopped finding
|
|
644
|
+
// it — reporting the route as "unreachable" or "not mounted" when nothing was wrong with
|
|
645
|
+
// it. Canonicalising at the scanner fixes all of them in one place; doing it per-consumer
|
|
646
|
+
// is how the contract and the gate list drift apart.
|
|
647
|
+
//
|
|
648
|
+
// The CONSTRAINT ITSELF still matters to one reader: scripts/gds/route-shadow-guard.js
|
|
649
|
+
// reads raw source with its own regex, precisely because a constrained param is the thing
|
|
650
|
+
// that makes a route safe to mount before a literal. That guard must keep seeing it.
|
|
651
|
+
const PARAM_CONSTRAINT_RE = /:([A-Za-z0-9_]+)\([^)]*\)/g;
|
|
652
|
+
|
|
653
|
+
function canonicalRoutePath(p) {
|
|
654
|
+
return p == null ? p : String(p).replace(PARAM_CONSTRAINT_RE, ':$1');
|
|
655
|
+
}
|
|
656
|
+
|
|
633
657
|
// The literal path a declaration at line `i` names, or null when it is not statically
|
|
634
|
-
// resolvable (a template literal, a variable, a computed path).
|
|
658
|
+
// resolvable (a template literal, a variable, a computed path). Inline param constraints
|
|
659
|
+
// are canonicalised away — see canonicalRoutePath.
|
|
635
660
|
function routePathAt(lines, i) {
|
|
636
661
|
const same = ROUTE_DECL_SAME_LINE_RE.exec(lines[i]);
|
|
637
|
-
if (same) return same[2];
|
|
662
|
+
if (same) return canonicalRoutePath(same[2]);
|
|
638
663
|
// Multi-line form: nothing but whitespace/comment may follow the '(' …
|
|
639
664
|
const rest = lines[i].slice(lines[i].indexOf('(') + 1).trim();
|
|
640
665
|
if (rest !== '' && !rest.startsWith('//')) return null;
|
|
@@ -643,7 +668,7 @@ function routePathAt(lines, i) {
|
|
|
643
668
|
const t = lines[j].trim();
|
|
644
669
|
if (t === '' || t.startsWith('//')) continue;
|
|
645
670
|
const m = ROUTE_PATH_LINE_RE.exec(lines[j]);
|
|
646
|
-
return m ? m[1] : null;
|
|
671
|
+
return m ? canonicalRoutePath(m[1]) : null;
|
|
647
672
|
}
|
|
648
673
|
return null;
|
|
649
674
|
}
|
|
@@ -939,6 +964,7 @@ module.exports = {
|
|
|
939
964
|
classifyRank,
|
|
940
965
|
classifyText,
|
|
941
966
|
scanDeclarations,
|
|
967
|
+
canonicalRoutePath,
|
|
942
968
|
discoverRouteFiles,
|
|
943
969
|
checkRouteRanks,
|
|
944
970
|
toWorkerResult,
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.627'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/api_docs.mjs
CHANGED
|
@@ -76,6 +76,34 @@ t('pathParams: :id → {id} and names captured', () => {
|
|
|
76
76
|
assert.deepEqual(r.names, ['id']);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
// task 1003747: an Express inline param constraint is a ROUTING detail and must
|
|
80
|
+
// never reach the published contract. Before this, `/inbox/:id(\d+)` produced the
|
|
81
|
+
// malformed path template `/inbox/{id}(\\d+)` and silently renamed the
|
|
82
|
+
// operationId `get_inbox_id` → `get_inbox_id_d` — a breaking change for every
|
|
83
|
+
// generated-client consumer, from a change that altered no URL.
|
|
84
|
+
t('pathParams: an inline param constraint is stripped from the path template', () => {
|
|
85
|
+
const r = gen.pathParams('/inbox/:id(\\d+)');
|
|
86
|
+
assert.equal(r.openapiPath, '/inbox/{id}');
|
|
87
|
+
assert.deepEqual(r.names, ['id']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
t('pathParams: a constrained param yields the SAME template as a bare one', () => {
|
|
91
|
+
assert.equal(gen.pathParams('/inbox/:id(\\d+)').openapiPath,
|
|
92
|
+
gen.pathParams('/inbox/:id').openapiPath);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
t('routeForDisplay: the reader sees /inbox/:id, never the regex', () => {
|
|
96
|
+
assert.equal(gen.routeForDisplay('/inbox/:id(\\d+)'), '/inbox/:id');
|
|
97
|
+
assert.equal(gen.routeForDisplay('/tasks/:id/water'), '/tasks/:id/water');
|
|
98
|
+
assert.equal(gen.routeForDisplay(''), '');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
t('the published spec contains no leaked param constraint', () => {
|
|
102
|
+
const spec = JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'api', 'openapi.json'), 'utf8'));
|
|
103
|
+
const leaked = Object.keys(spec.paths || {}).filter((p) => p.includes('(') || p.includes('\\'));
|
|
104
|
+
assert.deepEqual(leaked, [], `path templates must carry no regex: ${leaked.join(', ')}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
79
107
|
t('topLevelKeys: depth-1 keys only, ignores nested min/max + inline comments', () => {
|
|
80
108
|
const lit = `{
|
|
81
109
|
title: { required: true, type: 'string', maxLength: 200 },
|
package/tests/helpers.mjs
CHANGED
|
@@ -148,6 +148,34 @@ export function lifecycleDbSource(modulesDir = path.join(ROOT, 'modules')) {
|
|
|
148
148
|
(f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')));
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// assertCannotSwallow(assert, paths, literal, paramPath, label) — the route-ordering
|
|
152
|
+
// invariant for a literal sharing a prefix with a `:param` route, e.g.
|
|
153
|
+
// `/inbox/awaiting-nod` against `/inbox/:id` (task 1003747).
|
|
154
|
+
//
|
|
155
|
+
// WHY THIS IS NOT JUST AN indexOf. Four test files each asserted
|
|
156
|
+
// `indexOf(literal) < indexOf('/inbox/:id')`, reading the LIVE router's
|
|
157
|
+
// layer.route.path. That was the whole invariant while the param route was bare —
|
|
158
|
+
// Express matches in registration order, so a literal declared after it arrives as
|
|
159
|
+
// `:id='awaiting-nod'` and 400s on parseId: a bug whose only symptom is an empty card.
|
|
160
|
+
//
|
|
161
|
+
// It stopped being the whole invariant when `/inbox/:id` gained the constraint
|
|
162
|
+
// `(\d+)`. A CONSTRAINED param cannot match a non-numeric segment at all, so it cannot
|
|
163
|
+
// swallow the literal REGARDLESS of order — and the four tests, looking for the bare
|
|
164
|
+
// string, instead reported the param route as "not mounted" and failed. Order-only was
|
|
165
|
+
// always the narrower claim; the real property is that the literal is REACHABLE. This
|
|
166
|
+
// asserts that directly, so both spellings pass for the right reason and neither adding
|
|
167
|
+
// nor removing a constraint produces a false failure.
|
|
168
|
+
export function assertCannotSwallow(assert, paths, literal, paramPath, label = paramPath) {
|
|
169
|
+
const iLit = paths.indexOf(literal);
|
|
170
|
+
assert.ok(iLit >= 0, `${literal} is not mounted at all`);
|
|
171
|
+
const bare = paths.indexOf(paramPath);
|
|
172
|
+
const constrained = paths.findIndex((p) => typeof p === 'string' && p.startsWith(`${paramPath}(`));
|
|
173
|
+
assert.ok(bare >= 0 || constrained >= 0, `${label} is not mounted at all`);
|
|
174
|
+
if (constrained >= 0) return; // constrained ⇒ cannot match a non-numeric segment; order is moot
|
|
175
|
+
assert.ok(iLit < bare,
|
|
176
|
+
`${literal} (${iLit}) must precede the unconstrained ${label} (${bare}), or ${label} swallows it`);
|
|
177
|
+
}
|
|
178
|
+
|
|
151
179
|
// The SHIP CLI: ship.js (facade + entry point) + the ship-*.js phase modules.
|
|
152
180
|
export function shipCliSource(scriptsDir = path.join(ROOT, 'scripts', 'gds')) {
|
|
153
181
|
return familySource(scriptsDir,
|
|
@@ -25,6 +25,7 @@ import { createRequire } from 'node:module';
|
|
|
25
25
|
import fs from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { assertCannotSwallow } from './helpers.mjs';
|
|
28
29
|
|
|
29
30
|
const require = createRequire(import.meta.url);
|
|
30
31
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -162,10 +163,10 @@ await test('a draft written against another template is dropped, not half-restor
|
|
|
162
163
|
// --- GET /inbox/template ------------------------------------------------
|
|
163
164
|
|
|
164
165
|
await test('GET /inbox/template is declared BEFORE GET /inbox/:id', () => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
assert
|
|
166
|
+
// Declaration order OR a constrained param keeps this reachable — with a bare :id
|
|
167
|
+
// first, "/inbox/template" binds id="template" and parseId 400s it. See
|
|
168
|
+
// assertCannotSwallow in tests/helpers.mjs (task 1003747).
|
|
169
|
+
assertCannotSwallow(assert, mounted, 'GET /inbox/template', 'GET /inbox/:id');
|
|
169
170
|
});
|
|
170
171
|
|
|
171
172
|
await test('the template read returns the R141 declaration, in fieldOrder order', async () => {
|
package/tests/idea_routing.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { strict as assert } from 'node:assert';
|
|
28
28
|
import { createRequire } from 'node:module';
|
|
29
|
-
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
29
|
+
import { makeRunner, makeSqlAwareClient, assertCannotSwallow } from './helpers.mjs';
|
|
30
30
|
|
|
31
31
|
process.env.NODE_ENV = 'test';
|
|
32
32
|
|
|
@@ -989,10 +989,9 @@ await test('GET /inbox/awaiting-nod is registered BEFORE /inbox/:id, or it is un
|
|
|
989
989
|
// a bug with no symptom except an empty card.
|
|
990
990
|
const router = require('../modules/ideas/routes/inbox.js')({});
|
|
991
991
|
const paths = router.stack.filter((l) => l.route).map((l) => l.route.path);
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
assert
|
|
995
|
-
assert.ok(iNod < iParam, `literal segment must precede the wildcard (got ${iNod} vs ${iParam})`);
|
|
992
|
+
// Order OR a constrained param — either makes this route reachable. See
|
|
993
|
+
// assertCannotSwallow in tests/helpers.mjs (task 1003747).
|
|
994
|
+
assertCannotSwallow(assert, paths, '/inbox/awaiting-nod', '/inbox/:id');
|
|
996
995
|
});
|
|
997
996
|
|
|
998
997
|
// =========================================================================
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { strict as assert } from 'node:assert';
|
|
22
22
|
import { createRequire } from 'node:module';
|
|
23
|
+
import { assertCannotSwallow } from './helpers.mjs';
|
|
23
24
|
|
|
24
25
|
const require = createRequire(import.meta.url);
|
|
25
26
|
const api = require('../src/module-api.js');
|
|
@@ -81,14 +82,10 @@ async function test(name, fn) {
|
|
|
81
82
|
// --- declaration order: the bug that would 400 the whole queue -----------
|
|
82
83
|
|
|
83
84
|
await test('GET /inbox/sparks is declared BEFORE GET /inbox/:id', () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
assert
|
|
88
|
-
assert.ok(
|
|
89
|
-
iSparks < iId,
|
|
90
|
-
`declaration order is load-bearing: with :id first, "/inbox/sparks" binds id="sparks" and parseId 400s the queue (sparks@${iSparks}, :id@${iId})`,
|
|
91
|
-
);
|
|
85
|
+
// Declaration order OR a constrained param keeps this reachable — with a bare :id
|
|
86
|
+
// first, "/inbox/sparks" binds id="sparks" and parseId 400s the queue. See
|
|
87
|
+
// assertCannotSwallow in tests/helpers.mjs (task 1003747).
|
|
88
|
+
assertCannotSwallow(assert, mounted, 'GET /inbox/sparks', 'GET /inbox/:id');
|
|
92
89
|
});
|
|
93
90
|
|
|
94
91
|
// --- the predicate: open, undeveloped sparks, newest first ---------------
|