@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 +35 -25
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/goal-inbox.js +12 -1
- package/modules/ideas/routes/inbox.js +36 -2
- 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/route_shadow_guard.mjs +215 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 2096,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
1910
|
+
"sha256": "da4d2796fa7ecbfb26c308cb7d1c5074c8c7d0d76d98995549166f265c53c656"
|
|
1911
1911
|
},
|
|
1912
1912
|
{
|
|
1913
1913
|
"path": "docs/copy-registry.json",
|
|
1914
1914
|
"mode": "0000644",
|
|
1915
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
7685
|
+
"sha256": "008a893c7efba51a8e8b7feff7b0569454c3b85670369ad736c28ba25b331c75"
|
|
7686
7686
|
},
|
|
7687
7687
|
{
|
|
7688
7688
|
"path": "package.json",
|
|
7689
7689
|
"mode": "0000644",
|
|
7690
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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",
|
package/docs/copy-inventory.md
CHANGED
|
@@ -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:
|
|
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` |
|
package/docs/copy-registry.json
CHANGED
|
@@ -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(() => {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
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.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');
|
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 ---------------
|
|
@@ -35,6 +35,7 @@ import fs from 'node:fs';
|
|
|
35
35
|
import path from 'node:path';
|
|
36
36
|
import vm from 'node:vm';
|
|
37
37
|
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { assertCannotSwallow } from './helpers.mjs';
|
|
38
39
|
|
|
39
40
|
const require = createRequire(import.meta.url);
|
|
40
41
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -79,11 +80,9 @@ const ideaRouter = ideaRoutesFactory({});
|
|
|
79
80
|
const mountedPaths = ideaRouter.stack.filter((l) => l.route).map((l) => l.route.path);
|
|
80
81
|
|
|
81
82
|
await test('GET /inbox/by-builder/:id is declared BEFORE GET /inbox/:id', () => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
assert
|
|
85
|
-
assert.ok(byId > -1, 'the by-id route is mounted');
|
|
86
|
-
assert.ok(trail < byId, `/inbox/by-builder/:id (${trail}) must precede /inbox/:id (${byId}) or :id swallows it`);
|
|
83
|
+
// Order OR a constrained param — either makes the trail route reachable. See
|
|
84
|
+
// assertCannotSwallow in tests/helpers.mjs (task 1003747).
|
|
85
|
+
assertCannotSwallow(assert, mountedPaths, '/inbox/by-builder/:id', '/inbox/:id');
|
|
87
86
|
});
|
|
88
87
|
|
|
89
88
|
// ---------------------------------------------------------------------------
|
package/tests/prose_edits.mjs
CHANGED
|
@@ -160,7 +160,12 @@ await test('ROUTE SHAPE: the triage-gated PATCH /inbox/:id was NOT widened to re
|
|
|
160
160
|
assert.ok(row, 'the triage route still exists');
|
|
161
161
|
assert.equal(row.rank, 'metic+archon', 'PATCH /inbox/:id must keep its triage-level gate');
|
|
162
162
|
const src = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
|
|
163
|
-
|
|
163
|
+
// Tolerate an inline param constraint on the path — `'/inbox/:id(\d+)'` (task
|
|
164
|
+
// 1003747, which added one so this route stops swallowing another module's
|
|
165
|
+
// `/inbox/rotting`). Matching the bare literal made this test report the triage
|
|
166
|
+
// route as missing when only its MATCHER had been narrowed. The route key above
|
|
167
|
+
// is canonicalised for the same reason (src/bongos/route-rank-check.js).
|
|
168
|
+
const start = src.search(/router\.patch\('\/inbox\/:id(?:\([^)]*\))?',/);
|
|
164
169
|
const body = src.slice(start, src.indexOf('router.', start + 20));
|
|
165
170
|
// Assert on the VALIDATION SCHEMA, not on any mention of the string: that route
|
|
166
171
|
// already discusses body_md in a comment (about it not being parseable as a
|
|
@@ -0,0 +1,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);
|