@bongos/core 1.19.625 → 1.19.626

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.625",
6
- "core_contract": "1.19.625",
7
- "source_commit": "19a2b295b0d1a0755168ec39b249dc1a5f9b961e",
5
+ "core_version": "1.19.626",
6
+ "core_contract": "1.19.626",
7
+ "source_commit": "15d768a9fe3239e170150c8d2efb98e6de8d98da",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-09T08:21:23.081Z",
9
+ "built_at": "2026-09-09T14:24:41.469Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 464,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2094,
14
+ "functional_verbatim": 2096,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2582,
20
- "tree_sha256": "c6d6c5f0e0b67618fd3d94d6beb816506c9d15c480cc0dc0de0768b4b27cce23",
19
+ "file_count": 2584,
20
+ "tree_sha256": "1fbadd48214869e3a766dde80f634c74c81a7ee96bf480937417526afe50a27b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -1907,12 +1907,12 @@
1907
1907
  {
1908
1908
  "path": "docs/copy-inventory.md",
1909
1909
  "mode": "0000644",
1910
- "sha256": "69a114c124cf97c86e7449366d4abfc1cc193649c1abdc2b93655f45886c7bfc"
1910
+ "sha256": "da4d2796fa7ecbfb26c308cb7d1c5074c8c7d0d76d98995549166f265c53c656"
1911
1911
  },
1912
1912
  {
1913
1913
  "path": "docs/copy-registry.json",
1914
1914
  "mode": "0000644",
1915
- "sha256": "bfd0e82c97405348f7d2f5bb7cacfa3143adab91f5b9ebfcd7fa8d7a804e76f7"
1915
+ "sha256": "222db57e3effc7706591e17b7ab901b198c79a46212c16b3221d1bb2b31754e2"
1916
1916
  },
1917
1917
  {
1918
1918
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2757,7 +2757,7 @@
2757
2757
  {
2758
2758
  "path": "docs/module-api-changelog.md",
2759
2759
  "mode": "0000644",
2760
- "sha256": "6979862e5d574d45a8cf53196775cb2f3c53e601e4f812e3bd69c3b3a9c7cc15"
2760
+ "sha256": "19d4d61c3cd024932aeb068335ef1d9d69e37a59a0e7d653a5e6136bb4972c19"
2761
2761
  },
2762
2762
  {
2763
2763
  "path": "docs/modules-contract.md",
@@ -4912,7 +4912,7 @@
4912
4912
  {
4913
4913
  "path": "modules/hall-ui/public/goal-inbox.js",
4914
4914
  "mode": "0000644",
4915
- "sha256": "524a475dd6ab05e805e8db6baf11c90e10a3e6e76d7a423ffe571cafe61c0324"
4915
+ "sha256": "0e6aacc6e720d1865c45f71b2458f23c5bac8f1643082256f4d7e71d97078994"
4916
4916
  },
4917
4917
  {
4918
4918
  "path": "modules/hall-ui/public/goals-lib.js",
@@ -5507,7 +5507,7 @@
5507
5507
  {
5508
5508
  "path": "modules/ideas/routes/inbox.js",
5509
5509
  "mode": "0000644",
5510
- "sha256": "8dec7682099d131ca050487dafe259bf4c9b511103c472cd0cbce54b4b9a898f"
5510
+ "sha256": "5e7bdb2c09e1e62aacb4b40843392e2c1ca1534a69191c00c3103a257f086c31"
5511
5511
  },
5512
5512
  {
5513
5513
  "path": "modules/ideas/routing.js",
@@ -7682,12 +7682,12 @@
7682
7682
  {
7683
7683
  "path": "package-lock.json",
7684
7684
  "mode": "0000644",
7685
- "sha256": "b1bf1316b6bf58eede860ae199233be76e1beb440d2cf7f4c96171e8f6347f05"
7685
+ "sha256": "008a893c7efba51a8e8b7feff7b0569454c3b85670369ad736c28ba25b331c75"
7686
7686
  },
7687
7687
  {
7688
7688
  "path": "package.json",
7689
7689
  "mode": "0000644",
7690
- "sha256": "1e54c8559b66bcda15cbfb24e216bb5d3421a9e0c9d07f240c2414019288fe9a"
7690
+ "sha256": "a8f88df15fae0f2566f18b0d78985e82d8d6ab5c72ee35cdf1598130ac2e8ce3"
7691
7691
  },
7692
7692
  {
7693
7693
  "path": "public-docs/index.html",
@@ -8142,7 +8142,7 @@
8142
8142
  {
8143
8143
  "path": "scripts/gds/fitness.js",
8144
8144
  "mode": "0000644",
8145
- "sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
8145
+ "sha256": "cd9f61f69ef8d017a39bed129e41a3d3944cf53bfaf6ff26eb868e46611b6dc3"
8146
8146
  },
8147
8147
  {
8148
8148
  "path": "scripts/gds/gate-review.js",
@@ -8187,7 +8187,7 @@
8187
8187
  {
8188
8188
  "path": "scripts/gds/gen-api-docs.js",
8189
8189
  "mode": "0000644",
8190
- "sha256": "03b4263f19c0f7157160011f43a174599e5294f0b62fdaf3a6e943e9bfeec855"
8190
+ "sha256": "f9c0d6a1fb24158109d2753c9c2bcb79060a67198a7396ed7ba85bdeadfdeaca"
8191
8191
  },
8192
8192
  {
8193
8193
  "path": "scripts/gds/gen-atlas.js",
@@ -8574,6 +8574,11 @@
8574
8574
  "mode": "0000644",
8575
8575
  "sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
8576
8576
  },
8577
+ {
8578
+ "path": "scripts/gds/route-shadow-guard.js",
8579
+ "mode": "0000644",
8580
+ "sha256": "be2196b6f1e8859924d5f6b50c5a0cb476938ea70d37b5f820713dd8f5fd15f7"
8581
+ },
8577
8582
  {
8578
8583
  "path": "scripts/gds/routine-schedule.js",
8579
8584
  "mode": "0000644",
@@ -9282,7 +9287,7 @@
9282
9287
  {
9283
9288
  "path": "src/bongos/route-rank-check.js",
9284
9289
  "mode": "0000644",
9285
- "sha256": "96b884a69b7b709a256d220c97a972f9827d74a36a864c216a7b37db3fdf66be"
9290
+ "sha256": "ad2d07652864d577736f6c0f83e27ff197fe2201796eb06c5577743c16119a5b"
9286
9291
  },
9287
9292
  {
9288
9293
  "path": "src/bongos/routes.js",
@@ -9422,7 +9427,7 @@
9422
9427
  {
9423
9428
  "path": "src/module-api.js",
9424
9429
  "mode": "0000644",
9425
- "sha256": "7a6f5a46c866658041e6299fe46188834184f510dd496edef5cb2f7b0c414088"
9430
+ "sha256": "13157fd8a1bf1a5d1b892a3deed40c9fc1a85c7daf5db9bf5283837e261a6081"
9426
9431
  },
9427
9432
  {
9428
9433
  "path": "src/module-loader/catalog.js",
@@ -9542,7 +9547,7 @@
9542
9547
  {
9543
9548
  "path": "tests/api_docs.mjs",
9544
9549
  "mode": "0000644",
9545
- "sha256": "56c093b994b9539ac93fdf206efe00b0c4318e01f65f4fbe377c31981c7c5bd6"
9550
+ "sha256": "b8725188b7385ec26d35365256cd3eab25d62eaeabfeab6a979de0a447b77b04"
9546
9551
  },
9547
9552
  {
9548
9553
  "path": "tests/api_error_envelope_buildcheck.mjs",
@@ -11057,7 +11062,7 @@
11057
11062
  {
11058
11063
  "path": "tests/helpers.mjs",
11059
11064
  "mode": "0000644",
11060
- "sha256": "b3f0a87c51c9e655c712db00a0a8763e2fc08e4de3c6904c6080385e7c7dad14"
11065
+ "sha256": "6a2360675b70ff2b32ca514e2baafa2aecb10e397a2f88630928ee93b351b34b"
11061
11066
  },
11062
11067
  {
11063
11068
  "path": "tests/hierarchy-config.mjs",
@@ -11157,7 +11162,7 @@
11157
11162
  {
11158
11163
  "path": "tests/idea_hall_composer.mjs",
11159
11164
  "mode": "0000644",
11160
- "sha256": "dbe8f91e4f3acc566a36547ce42d30328ba9e651a622fe1f25092d49d4e3a955"
11165
+ "sha256": "84c5bb805688c484ea83d31e371f1b25fba1daaeb380cba41a21594ddb724de1"
11161
11166
  },
11162
11167
  {
11163
11168
  "path": "tests/idea_ideate_full.mjs",
@@ -11182,7 +11187,7 @@
11182
11187
  {
11183
11188
  "path": "tests/idea_routing.mjs",
11184
11189
  "mode": "0000644",
11185
- "sha256": "d4d4e6ce60f1524e821e0c6c9f2844c7ba791b5502072dc00496224404afa990"
11190
+ "sha256": "3a2c5d2e6c3231f69847e1e2aea073e78caf26228b500dbdf612b5bae354d0a2"
11186
11191
  },
11187
11192
  {
11188
11193
  "path": "tests/idea_spark_hall.mjs",
@@ -11197,7 +11202,7 @@
11197
11202
  {
11198
11203
  "path": "tests/idea_spark_queue.mjs",
11199
11204
  "mode": "0000644",
11200
- "sha256": "43f801610e37062c54e631904aecaa10aa2486139aba3aa9eeb820d84fd32368"
11205
+ "sha256": "8bfc36537ffccae702e74e8763534fa535486a2eb8bbf9ee546a2c7368d1dc6b"
11201
11206
  },
11202
11207
  {
11203
11208
  "path": "tests/idea_task_lineage.mjs",
@@ -11772,7 +11777,7 @@
11772
11777
  {
11773
11778
  "path": "tests/profile_activity.mjs",
11774
11779
  "mode": "0000644",
11775
- "sha256": "013df2528c4998588c1d3f89a9a25f8fa1e912b8abc2d8e95c26c250869a386f"
11780
+ "sha256": "a2c62f9c4acf6f7e683766ae29a616c9325465468a86f38c1d512f58efd124df"
11776
11781
  },
11777
11782
  {
11778
11783
  "path": "tests/profile_connections_ui.mjs",
@@ -11902,7 +11907,7 @@
11902
11907
  {
11903
11908
  "path": "tests/prose_edits.mjs",
11904
11909
  "mode": "0000644",
11905
- "sha256": "a613b7cfa675db5231c44319146333d173d2dfa53da092654d5bd11a141483f4"
11910
+ "sha256": "a36acb19e59db1f9eb885ab6497a43d1c8ce0ba6198b0f5cc98f33bf08837fdf"
11906
11911
  },
11907
11912
  {
11908
11913
  "path": "tests/provision.mjs",
@@ -12164,6 +12169,11 @@
12164
12169
  "mode": "0000644",
12165
12170
  "sha256": "bdc58224050392ece492ae8e8f12f62a8934d86e3993e0df5b8cb0aac80602e8"
12166
12171
  },
12172
+ {
12173
+ "path": "tests/route_shadow_guard.mjs",
12174
+ "mode": "0000644",
12175
+ "sha256": "552479822e1349a56bf68c636a35787d44da2abacf6917339ce5ce39223b9f2d"
12176
+ },
12167
12177
  {
12168
12178
  "path": "tests/route_validation.mjs",
12169
12179
  "mode": "0000644",
@@ -597,7 +597,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
597
597
  | `ae84ebe1d735` | certain | Name cannot be blank. | `modules/hall-ui/public/settings.js:831` |
598
598
  | `31e91ee6ce03` | certain | Name must be {…} characters or fewer. | `modules/hall-ui/public/settings.js:836` |
599
599
  | `5e8199266a8a` | certain | Name unvouched | `modules/hall-ui/public/watch.js:350` |
600
- | `7def635421ea` | certain | Needs your attention | `modules/hall-ui/public/goal-inbox.js:385` |
600
+ | `7def635421ea` | certain | Needs your attention | `modules/hall-ui/public/goal-inbox.js:396` |
601
601
  | `87f09d983f6c` | certain | New category label | `modules/hall-ui/public/project-settings.js:138` |
602
602
  | `d427d9dab01c` | certain | New claims are paused: {…} confirmed {…} can't land yet, so {…} reward is pending. Rebase and re-ship, or rele | `modules/hall-ui/public/hall-render.js:618` |
603
603
  | `7fcc2583c372` | certain | New custom rank | `modules/hall-ui/public/government.html:79` |
@@ -6401,7 +6401,7 @@
6401
6401
  "surface": "builders-hall",
6402
6402
  "text": "Needs your attention",
6403
6403
  "file": "modules/hall-ui/public/goal-inbox.js",
6404
- "line": 385,
6404
+ "line": 396,
6405
6405
  "origin": "js-markup",
6406
6406
  "confidence": "certain"
6407
6407
  },
@@ -1699,5 +1699,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1699
1699
  landed since 1.19.623 with no explicit bump. run 34326009063. (task 1002620)
1700
1700
  1.19.625 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1701
1701
  landed since 1.19.624 with no explicit bump. run 34328603214. (task 1002620)
1702
+ 1.19.626 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1703
+ landed since 1.19.625 with no explicit bump. run 34363350286. (task 1002620)
1702
1704
  ---------------------------------------------------------------------------
1703
1705
  ```
@@ -370,7 +370,18 @@
370
370
  function loadRot() {
371
371
  return getJSON(`${API}/inbox/rotting`)
372
372
  .then((data) => renderRot(data))
373
- .catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
373
+ .catch((err) => {
374
+ // STILL FAIL-OPEN — a rot feed that cannot load must never break the hall,
375
+ // and being signed out or on an older server are both ordinary. But it now
376
+ // SAYS SO (task 1003747). The bare `.catch(() => {})` this replaces is how
377
+ // the feed stayed dead in production for releases: /inbox/rotting was being
378
+ // shadowed by another module's `/inbox/:id` and answering 400 bad_id, and
379
+ // this handler swallowed it into an empty section that looked like "nothing
380
+ // is rotting". A silent catch on the ONLY caller of a route is indistin-
381
+ // guishable from a healthy empty state — which is the whole failure.
382
+ console.warn('[hall] rot feed unavailable — the "Going quiet" section is empty '
383
+ + 'because the request failed, NOT because nothing is rotting:', err);
384
+ });
374
385
  }
375
386
 
376
387
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
@@ -526,13 +526,41 @@ module.exports = function buildInboxRouter() {
526
526
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
527
527
  }, { errorCode: 'idea_trail_failed' }));
528
528
 
529
+ // THE `(\d+)` ON THE TWO BARE-ID ROUTES BELOW IS LOAD-BEARING, NOT DECORATION
530
+ // (task 1003747). An idea id is always a positive integer, and constraining the
531
+ // param to digits is what stops these routes from swallowing a LITERAL
532
+ // `/inbox/<word>` contributed by ANOTHER module. It swallowed exactly that:
533
+ // `GET /inbox/rotting` (the rot feed, modules/lifecycle/routes/rot.js) answered
534
+ // 400 `bad_id` in production, because mountModuleRoutes
535
+ // (src/module-loader/loader.js) mounts modules in discovery order — `ideas`
536
+ // before `lifecycle` — so this matcher saw "rotting", failed to parse it as an
537
+ // id, and ended the request before the rot handler was ever reached. The whole
538
+ // 30-day rot cadence (ADR 0232) was dead and silent for releases.
539
+ //
540
+ // Declaring literals before `:id` (the convention modules/ideas/CLAUDE.md
541
+ // records) only fixes collisions INSIDE this file; it cannot reach a route
542
+ // another module contributes. The digit constraint can, because it makes the
543
+ // non-numeric path fall THROUGH to whatever is mounted later, whatever the
544
+ // order. `scripts/gds/route-shadow-guard.js` fails the build on any remaining
545
+ // pair of this shape, and gen-api-docs strips the constraint so it never
546
+ // reaches the published spec.
547
+ //
548
+ // Trade-off, taken deliberately: `/inbox/abc` now 404s ("no such route")
549
+ // instead of 400 `bad_id`. No test pinned that 400, and 404 is the more honest
550
+ // answer for a path that matches no route.
551
+ //
552
+ // The blank line below is deliberate: commentBlockAbove() in gen-api-docs.js
553
+ // walks upward from the route and stops at it, so THIS rationale stays in the
554
+ // source for the next reader without becoming the route's public API
555
+ // description.
556
+
529
557
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
530
558
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
531
559
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
532
560
  // would dead-end at a "no such idea" card. getIdea() reads any idea by id.
533
561
  // Authed: a logged-out resolve hits the hall's softAuth "sign in" card, so no
534
562
  // idea text is exposed publicly (the linked refs are public, the bodies aren't).
535
- router.get('/inbox/:id', api.requireBuilder, async (req, res) => {
563
+ router.get('/inbox/:id(\\d+)', api.requireBuilder, async (req, res) => {
536
564
  const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
537
565
  if (id === null) return;
538
566
  try {
@@ -1025,7 +1053,13 @@ module.exports = function buildInboxRouter() {
1025
1053
  return res.json({ ok: true, idea: result.idea, delta: result.delta, unchanged: !result.delta });
1026
1054
  }, { errorCode: 'idea_body_update_failed' }));
1027
1055
 
1028
- router.patch('/inbox/:id', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
1056
+ // `(\d+)` for the same reason as the GET above — see that comment. No PATCH
1057
+ // literal collides today; the constraint is here so the pair of bare-id routes
1058
+ // on this path stay symmetric and a future `PATCH /inbox/<word>` cannot be
1059
+ // quietly eaten. Kept above a blank line so it stays out of the published API
1060
+ // description, same as the GET's rationale.
1061
+
1062
+ router.patch('/inbox/:id(\\d+)', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
1029
1063
  if (validateOrRespond(req, res, {
1030
1064
  // Enum kept light — inbox.updateIdea returns a structured BAD_STATUS
1031
1065
  // error if the value falls outside the accepted set, and the supervisor
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.626",
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.626",
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.626",
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.626'; // 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 ---------------
@@ -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
- const trail = mountedPaths.indexOf('/inbox/by-builder/:id');
83
- const byId = mountedPaths.indexOf('/inbox/:id');
84
- assert.ok(trail > -1, 'the trail route is mounted');
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
  // ---------------------------------------------------------------------------
@@ -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
- const start = src.indexOf("router.patch('/inbox/:id',");
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,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);