@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.
@@ -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 0085: flagging a task security_sensitive RAISES its rank
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 0085: set a task's rank floor explicitly (the Archon override).
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
- const floor = deriveRequiredRank(task.touches, task.security_sensitive);
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
- async function updateTaskNeedsMigration(id, needsMigration) { return updateTaskColumn(id, 'needs_migration', needsMigration === true); }
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.625",
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.625",
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.625",
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",
@@ -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
- // :param {param}; also return the ordered param name list.
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.625'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -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
- const iT = mounted.indexOf('GET /inbox/template');
166
- const iId = mounted.indexOf('GET /inbox/:id');
167
- assert.ok(iT >= 0 && iId >= 0);
168
- assert.ok(iT < iId, `with :id first, "/inbox/template" binds id="template" and parseId 400s it (template@${iT}, :id@${iId})`);
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 () => {
@@ -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
- const iNod = paths.indexOf('/inbox/awaiting-nod');
993
- const iParam = paths.indexOf('/inbox/:id');
994
- assert.ok(iNod >= 0, 'the route exists');
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
- const iSparks = mounted.indexOf('GET /inbox/sparks');
85
- const iId = mounted.indexOf('GET /inbox/:id');
86
- assert.ok(iSparks >= 0, 'the spark queue route is mounted');
87
- assert.ok(iId >= 0, 'the by-id read is mounted');
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 ---------------