@bongos/core 1.23.2 → 1.23.4
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 +21 -16
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +4 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/module.js +91 -1
- package/src/bongos/module-listing.js +99 -27
- package/src/module-api.js +1 -1
- package/tests/module_store_cli.mjs +184 -0
- package/tests/module_store_listing.mjs +132 -3
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/bongos/package-core.js",
|
|
5
|
-
"core_version": "1.23.
|
|
6
|
-
"core_contract": "1.23.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.23.4",
|
|
6
|
+
"core_contract": "1.23.4",
|
|
7
|
+
"source_commit": "b78c52677b921b7b2a6f25834c77b7d516810aa9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-
|
|
9
|
+
"built_at": "2026-10-26T21:43:52.539Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 616,
|
|
13
13
|
"agent_docs_stubbed": 29,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3552,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 4198,
|
|
20
|
+
"tree_sha256": "e5ed2ca130fbbb98e21c941d9c51e72760f74f72e2d48d6d91a4e789bdd6ce10",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2937,12 +2937,12 @@
|
|
|
2937
2937
|
{
|
|
2938
2938
|
"path": "docs/module-api-changelog.md",
|
|
2939
2939
|
"mode": "0000644",
|
|
2940
|
-
"sha256": "
|
|
2940
|
+
"sha256": "3907ee1e7036c99afa1790df344933c6652b4d9ffe972715551ad9bdb2bb12d9"
|
|
2941
2941
|
},
|
|
2942
2942
|
{
|
|
2943
2943
|
"path": "docs/modules-contract.md",
|
|
2944
2944
|
"mode": "0000644",
|
|
2945
|
-
"sha256": "
|
|
2945
|
+
"sha256": "ed7ab0d4799a935bb296457ebdba667c38dcfea5ebaec72e61968cd3ad91a6eb"
|
|
2946
2946
|
},
|
|
2947
2947
|
{
|
|
2948
2948
|
"path": "docs/onboarding/diagrams/01-task-lifecycle.mmd",
|
|
@@ -10832,12 +10832,12 @@
|
|
|
10832
10832
|
{
|
|
10833
10833
|
"path": "package-lock.json",
|
|
10834
10834
|
"mode": "0000644",
|
|
10835
|
-
"sha256": "
|
|
10835
|
+
"sha256": "793a3d85e023eafb3f0eb63e7a1c1b10f07a66e331adfb63d06a05921ad2f451"
|
|
10836
10836
|
},
|
|
10837
10837
|
{
|
|
10838
10838
|
"path": "package.json",
|
|
10839
10839
|
"mode": "0000644",
|
|
10840
|
-
"sha256": "
|
|
10840
|
+
"sha256": "db95c78d828b9fd2117b5a7b4e12d741fb21856022a6830b6cd286017a065f6f"
|
|
10841
10841
|
},
|
|
10842
10842
|
{
|
|
10843
10843
|
"path": "public-docs/index.html",
|
|
@@ -10857,7 +10857,7 @@
|
|
|
10857
10857
|
{
|
|
10858
10858
|
"path": "release-notes.json",
|
|
10859
10859
|
"mode": "0000644",
|
|
10860
|
-
"sha256": "
|
|
10860
|
+
"sha256": "a491785c22b68e414bdf9668ca809f671a8927d06560aabfbc04ce3d56dfc131"
|
|
10861
10861
|
},
|
|
10862
10862
|
{
|
|
10863
10863
|
"path": "scripts/bongos/CLAUDE.md",
|
|
@@ -11747,7 +11747,7 @@
|
|
|
11747
11747
|
{
|
|
11748
11748
|
"path": "scripts/bongos/module.js",
|
|
11749
11749
|
"mode": "0000644",
|
|
11750
|
-
"sha256": "
|
|
11750
|
+
"sha256": "6fc19f18daa947837ee09d9d06010380144022ee6f637f3fd444f56cf558bfab"
|
|
11751
11751
|
},
|
|
11752
11752
|
{
|
|
11753
11753
|
"path": "scripts/bongos/move-escalation.js",
|
|
@@ -14427,7 +14427,7 @@
|
|
|
14427
14427
|
{
|
|
14428
14428
|
"path": "src/bongos/module-listing.js",
|
|
14429
14429
|
"mode": "0000644",
|
|
14430
|
-
"sha256": "
|
|
14430
|
+
"sha256": "3f7013b4e59f72fc89663461d6d5457e412624ae3d4c1df1d2a3b9a8d9168d02"
|
|
14431
14431
|
},
|
|
14432
14432
|
{
|
|
14433
14433
|
"path": "src/bongos/module-overrides.js",
|
|
@@ -14712,7 +14712,7 @@
|
|
|
14712
14712
|
{
|
|
14713
14713
|
"path": "src/module-api.js",
|
|
14714
14714
|
"mode": "0000644",
|
|
14715
|
-
"sha256": "
|
|
14715
|
+
"sha256": "e9850d0157caab4215194a5e69e04420b0d9e3b97a8d1867f8f84e9087b88930"
|
|
14716
14716
|
},
|
|
14717
14717
|
{
|
|
14718
14718
|
"path": "src/module-loader/catalog.js",
|
|
@@ -18484,6 +18484,11 @@
|
|
|
18484
18484
|
"mode": "0000644",
|
|
18485
18485
|
"sha256": "ba4233ed072c87df9dbc5c75af10655ad8eaaf9d8ccbe69df146052467a1279e"
|
|
18486
18486
|
},
|
|
18487
|
+
{
|
|
18488
|
+
"path": "tests/module_store_cli.mjs",
|
|
18489
|
+
"mode": "0000644",
|
|
18490
|
+
"sha256": "cf4df707f62fa142f106822c44404603f8ec32213d61b6707818f5649c443275"
|
|
18491
|
+
},
|
|
18487
18492
|
{
|
|
18488
18493
|
"path": "tests/module_store_delist.mjs",
|
|
18489
18494
|
"mode": "0000644",
|
|
@@ -18502,7 +18507,7 @@
|
|
|
18502
18507
|
{
|
|
18503
18508
|
"path": "tests/module_store_listing.mjs",
|
|
18504
18509
|
"mode": "0000644",
|
|
18505
|
-
"sha256": "
|
|
18510
|
+
"sha256": "5f0cb9cb6e3faf90d342e1d9e1767699b677b8630d874f1c2af9cb7f713ef530"
|
|
18506
18511
|
},
|
|
18507
18512
|
{
|
|
18508
18513
|
"path": "tests/module_store_publish.mjs",
|
|
@@ -3221,5 +3221,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3221
3221
|
landed since 1.23.0 with no explicit bump. run 40270556091. (task 1002620)
|
|
3222
3222
|
1.23.2 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3223
3223
|
landed since 1.23.1 with no explicit bump. run 40399986777. (task 1002620)
|
|
3224
|
+
1.23.3 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3225
|
+
landed since 1.23.2 with no explicit bump. run 40402796050. (task 1002620)
|
|
3226
|
+
1.23.4 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3227
|
+
landed since 1.23.3 with no explicit bump. run 40406624494. (task 1002620)
|
|
3224
3228
|
---------------------------------------------------------------------------
|
|
3225
3229
|
```
|
package/docs/modules-contract.md
CHANGED
|
@@ -176,6 +176,8 @@ closed on every unknown, including a ledger read it cannot complete.
|
|
|
176
176
|
|
|
177
177
|
**Attribution (`author`/`origin`/`license`/`maintainer`) is portable metadata, not a cross-Bongos link** — each repo runs its own Bongos with its own builder identities ([ADR 0100](adr/0100-two-repo-instance-model.md) §3), so credit travels *with the module* rather than through a foreign key ([ADR 0107](adr/0107-module-upstreaming-contribution-to-core-catalog.md) §2). `bongos module new` stamps all four as TODO placeholders (license defaults to `AGPL-3.0`) so a module carries its authorship from the day it's scaffolded, not bolted on later at upstream-submit time.
|
|
178
178
|
|
|
179
|
+
**`bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]`** (task 1003811) is the **store** from the terminal: what the catalog lists, in its order — listed modules ranked, then New ones, unranked — with each module's score, price and place (parent, and sub-category once approved). It asks `GET /store/modules`, the same read the hall's "In the store" group makes, and filters, ranks and caps nothing itself: the quality floor, the ranking and the cap of ten per sub-category live only in `src/bongos/module-listing.js`, so the hall and the terminal cannot disagree about which modules are the best. `tests/module_store_cli.mjs` sends the same queries through the API and the CLI and requires the same modules in the same order.
|
|
180
|
+
|
|
179
181
|
**`bongos module list [--catalog] [--search <text>] [--json]`** ([ADR 0107](adr/0107-module-upstreaming-contribution-to-core-catalog.md) §2/§3, [ADR 0167](adr/0167-module-catalog-source-vs-provenance.md)) is the browsable catalog from the terminal: every module on disk with its enabled state, its author + origin credit, and its upkeep posture. `--catalog` narrows to the **core-shipped** modules — the shared catalog an instance opts into (§6, default-off). Two axes travel on each row and they are *not* the same question: **`source`** (which tree ships it — the core package or this instance's own repo, read structurally from the loader's discovery root, [ADR 0108](adr/0108-instance-composition-configurable-root.md) §1) and **`provenance`** (who built it, derived from the declared `origin`). A core module that declares no attribution is credited to the platform and the credit is **marked inferred** (`*`) rather than left blank or silently invented. The hall's Modules tab renders the same model (`src/module-loader/catalog.js`) via `GET /modules`, so the two surfaces cannot drift.
|
|
180
182
|
|
|
181
183
|
**`bongos module check <key> [--sign-off "Name <email>"]`** ([ADR 0107](adr/0107-module-upstreaming-contribution-to-core-catalog.md) §4) is the submit-time pre-check a module must clear before it can be upstreamed. It reuses (never re-types) the public-mirror publish denylist (`scripts/bongos/publish-manifest.js`'s `matchesDeny`) to reject content that cannot ship under AGPL (game content, pixel art, copyrighted reference material) — deliberately the DENY half only, since a brand-new module is never on the publish ALLOWLIST until it's accepted into core (§6). It also requires `module.json`'s `license` to be AGPL-3.0 (inbound = outbound) and a declared `maintenance` posture — acceptance is a commitment to upkeep, so a module with no `maintenance.status`, or one already `deprecated`/`orphaned`, is refused ([ADR 0166](adr/0166-module-maintenance-and-deprecation-policy.md); see below). On a pass, `--sign-off` records a lightweight DCO-style affirmation (own work, licensed AGPL-3.0 — never a copyright-assignment CLA) to `modules/<key>/.upstream-signoff.json`.
|
|
@@ -398,7 +400,8 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/bo
|
|
|
398
400
|
- **What a module is strong and weak at** (task 1003799, ADR 0343 D4). Each catalog entry also carries `parts` (the current result of Tests, Clean installs, Reliability, Tester feedback and How-to) and a reading of them: `strengths` (a part scored **80 or more**), `weaknesses` (a part scored **below the floor**) and `unmeasured` (a part with no score yet, which is never counted as weak). Each strength and weakness has a one-sentence `detail` saying what the number rests on. Security is not among them: every listed version has passed it. The reading is `readParts` in `src/bongos/module-listing.js`; the hall's Modules tab shows it in its "In the store" group, so two modules with the same overall score can be told apart.
|
|
399
401
|
- **Sorting by a part** (task 1003800). `GET /store/modules?sort=<part>` orders the listed modules by one part a buyer cares about: `tests`, `install`, `reliability`, `tester_feedback` or `docs` (the response lists them as `sort_parts`, and says which one it applied as `sort`). Modules with a score for that part come first, highest first; a tie goes to the higher overall score, then the module key. A module with no score for that part goes last, so missing data never wins or loses a place on its own. New modules are not sorted: they are never ranked. Any other `sort` value is refused with a 400. Without `sort`, the listed modules are in the ranking below; New stays in key order. The rule is `sortByPart` in `src/bongos/module-listing.js`.
|
|
400
402
|
- **The ranking, and a maximum price** (task 1003808, owner decision 2026-10-23). Without `sort`, listed modules are ranked **quality first**: highest overall score first; between equal scores, free modules first, then the lowest cash price, then the lowest credit price, then the module key. Price never buys a better place, it only orders modules the score could not tell apart (and it is never an input to the score, ADR 0343 D6). The response states the rule as `ranking`, and each entry carries its current `price` (`cents`, `credits`, `free`). A buyer may add `?max_cents=` and/or `?max_credits=` (whole numbers, 0 or more): modules that cost more are left out of both lists, free modules always stay, and a priced module stays when it is offered at or under a cap the buyer set. The applied caps come back as `max_price`. The rules are `rankListed` and `withinMaxPrice` in `src/bongos/module-listing.js`.
|
|
401
|
-
- **
|
|
403
|
+
- **The cap: at most ten per sub-category** (task 1003809, [ADR 0342](adr/0342-module-categories-are-eight-parents-with-approved-sub-categories.md) D2). Above the floor, at most **ten** modules are listed in each approved sub-category: the first ten in the ranking above, so a buyer's `sort` or maximum price reorders or narrows those ten but never lets the eleventh in. The cap is never applied to a parent. A module whose sub-category is only proposed is listed under its parent and counts toward no cap, and a New module is not ranked, so it neither takes a slot nor is held by one. The catalog returns the cap as `cap`. The rule is `applyCap` in `src/bongos/module-listing.js`.
|
|
404
|
+
- **Why a module is not listed** (task 1003810). `GET /store/modules/:key/listing` says where one module stands, judging the same newest version the catalog does: `listed` (true or false), a `reason` (`not_assessed`, `security_not_passed`, `no_score`, `below_floor`, `delisted`, `no_version`, `over_cap`, `new` or `at_or_above_floor`), `why` in a sentence and `next_step` saying what would change it. A failed Security gate is told apart from one that has not finished, and names what it found to the module's author only (to anyone else it would point at a weakness not fixed yet); below the floor names the score, the floor and the weakest parts. It carries the same `parts`, `strengths`, `weaknesses` and `unmeasured` as the catalog, plus `security`. Any signed-in builder can read it. Past the cap it is `over_cap`, which names the score, the module's place in its sub-category and the cap, so an author can tell being eleventh apart from being below the floor. Both responses carry the module's `category` (`parent`, `parent_title`, and `sub` and `label` once its sub-category is approved). The rule is `explainPlacement` in `src/bongos/module-listing.js`.
|
|
402
405
|
- **Sub-categories are approved before they count** (task 1004355, ADR 0342 D3). A module names `category: { parent, sub }` in `module.json`; `GET /store/subcategories` lists the approved ones under each of the eight parents. A `sub` the store has not seen under that parent is **proposed** at publish (labelled from its key) and the publish answer's `category` says so (`status: "proposed"`, `live: false`): until a Metic+ person decides, the module sits under its parent only and counts toward no cap. The review queue (`GET /store/subcategories/queue`, atom `module.subcategory.review`, metic + archon via `government_033`) shows each proposal with the modules that named it and its near-duplicate names; `POST /store/subcategories/:id/approve { label?, sub_key? }` makes it live, renamed first if asked (a rename onto an existing key is refused — merge instead), and `POST /store/subcategories/:id/merge { into }` folds it into an approved one and moves its modules there. A later publish naming a merged key lands on its target. Where a module sits is `store_module_categories` (core_278), apart from the manifest; `readModuleCategories` in `src/bongos/module-subcategories.js` is the read the cap uses.
|
|
403
406
|
- **Testing new versions first** (task 1003802, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) D1). A project can choose to get each new version of its modules before everyone else. It is the project that opts in, never one builder for someone else's project, and never by default. `GET /modules/testing` returns `{ tester, general_only, updated_at, updated_by }` to anyone signed in. `POST /modules/testing { tester: true|false }` switches it, and `POST /modules/:key/general-only { general_only: true|false }` keeps one module on the versions everyone gets while the rest are tested. Both writes take the `module.enable` permission, the same one that turns modules on and off. Both are reversible: switching testing off puts the project back on general versions from its next update, and a module's "general only" mark is kept if testing is switched off and on again. The choice is stored in the project's own settings (`modules.tester`, `modules.general_only`); anything that cannot be read counts as not testing. `testsVersionsOf` in `src/bongos/module-testing.js` is the one answer to "does this project get the tester version of this module", which installs and updates use (task 1003805). The hall's Modules tab has the switch in its "Testing new versions" group, and the general-only control in each module's details.
|
|
404
407
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.23.
|
|
9
|
+
"version": "1.23.4",
|
|
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.23.
|
|
3
|
+
"version": "1.23.4",
|
|
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/release-notes.json
CHANGED
|
@@ -10395,5 +10395,17 @@
|
|
|
10395
10395
|
"id": "1004355",
|
|
10396
10396
|
"text": "Module authors can now propose a new store sub-category when they publish, and Metic+ reviewers approve, rename or merge it, with look-alike names shown side by side so duplicates don't split the rankings."
|
|
10397
10397
|
}
|
|
10398
|
+
],
|
|
10399
|
+
"1.23.3": [
|
|
10400
|
+
{
|
|
10401
|
+
"id": "1003809",
|
|
10402
|
+
"text": "The module store now shows at most ten modules in each sub-category, and a module that lands eleventh tells its author why."
|
|
10403
|
+
}
|
|
10404
|
+
],
|
|
10405
|
+
"1.23.4": [
|
|
10406
|
+
{
|
|
10407
|
+
"id": "1003811",
|
|
10408
|
+
"text": "Builders can now browse the module store from the terminal with bongos module store, and it always shows the same modules in the same order as the hall."
|
|
10409
|
+
}
|
|
10398
10410
|
]
|
|
10399
10411
|
}
|
package/scripts/bongos/module.js
CHANGED
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
// model as the hall's Modules tab (src/module-loader/catalog.js), so the two
|
|
32
32
|
// surfaces cannot disagree about who built what.
|
|
33
33
|
//
|
|
34
|
+
// bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]
|
|
35
|
+
// Task 1003811 (criterion wa5-curated-listing) — the STORE from the terminal:
|
|
36
|
+
// what the catalog lists, in its order. It asks GET /store/modules, the same
|
|
37
|
+
// read the hall's "In the store" group makes, and never filters, ranks or caps
|
|
38
|
+
// anything itself — the quality floor, the ranking and the cap of ten per
|
|
39
|
+
// sub-category live in one place (src/bongos/module-listing.js), so the hall
|
|
40
|
+
// and the terminal cannot disagree about which modules are the best.
|
|
41
|
+
//
|
|
34
42
|
// bongos module check <key>
|
|
35
43
|
// ADR 0107 §4 — the submit-time pre-check a module must clear before it can
|
|
36
44
|
// be upstreamed into the shared core catalog. REUSES (never re-types) the
|
|
@@ -58,6 +66,7 @@
|
|
|
58
66
|
// bongos module new <key> scaffold modules/<key>/
|
|
59
67
|
// bongos module new <key> --force overwrite an existing modules/<key>/
|
|
60
68
|
// bongos module list [--catalog] browse the module catalog (author + origin credit)
|
|
69
|
+
// bongos module store [--sort <part>] [--max-cents N] [--max-credits N] what the store lists, best first
|
|
61
70
|
// bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
|
|
62
71
|
// bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
|
|
63
72
|
// bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
|
|
@@ -1060,6 +1069,84 @@ function storeError(res) {
|
|
|
1060
1069
|
return (err && (err.message || err.code)) || `HTTP ${res.status}`;
|
|
1061
1070
|
}
|
|
1062
1071
|
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
// bongos module store — what the store lists (task 1003811)
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
|
|
1077
|
+
|
|
1078
|
+
// The query GET /store/modules takes, from argv. Returns { query } or { error }.
|
|
1079
|
+
// The server checks the values too; this only refuses what is not a value at all.
|
|
1080
|
+
function storeQuery(args) {
|
|
1081
|
+
const params = [];
|
|
1082
|
+
for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
|
|
1083
|
+
const i = args.indexOf(flag);
|
|
1084
|
+
if (i === -1) continue;
|
|
1085
|
+
const v = args[i + 1];
|
|
1086
|
+
if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
|
|
1087
|
+
params.push(`${param}=${encodeURIComponent(v)}`);
|
|
1088
|
+
}
|
|
1089
|
+
return { query: params.length ? `?${params.join('&')}` : '' };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function formatPrice(price) {
|
|
1093
|
+
if (!price || price.free) return 'free';
|
|
1094
|
+
const parts = [];
|
|
1095
|
+
if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
|
|
1096
|
+
if (price.credits != null) parts.push(`${price.credits} credits`);
|
|
1097
|
+
return parts.join(' or ') || 'free';
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function formatPlace(category) {
|
|
1101
|
+
if (!category) return '';
|
|
1102
|
+
return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Pure renderer over the GET /store/modules body. It keeps the server's order and
|
|
1106
|
+
// adds nothing to it: listed in rank, then New, never ranked.
|
|
1107
|
+
function formatStore(body) {
|
|
1108
|
+
const out = [];
|
|
1109
|
+
out.push('bongos module store — what the store lists, best first');
|
|
1110
|
+
out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
|
|
1111
|
+
out.push(` ${body.ranking}`);
|
|
1112
|
+
if (body.sort) {
|
|
1113
|
+
const part = (body.sort_parts || []).find((x) => x.part === body.sort);
|
|
1114
|
+
out.push(` sorted by ${part ? part.title : body.sort}`);
|
|
1115
|
+
}
|
|
1116
|
+
const max = body.max_price;
|
|
1117
|
+
if (max) out.push(` at most ${[max.cents != null ? `$${(max.cents / 100).toFixed(2)}` : null, max.credits != null ? `${max.credits} credits` : null].filter(Boolean).join(' or ')}`);
|
|
1118
|
+
out.push('');
|
|
1119
|
+
const listed = body.listed || [];
|
|
1120
|
+
if (!listed.length) out.push(' Nothing is listed yet.');
|
|
1121
|
+
listed.forEach((m, i) => {
|
|
1122
|
+
out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
|
|
1123
|
+
});
|
|
1124
|
+
const fresh = body.new || [];
|
|
1125
|
+
if (fresh.length) {
|
|
1126
|
+
out.push('');
|
|
1127
|
+
out.push(' New — not ranked until projects have installed and used them:');
|
|
1128
|
+
for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
|
|
1129
|
+
}
|
|
1130
|
+
out.push('');
|
|
1131
|
+
out.push(' Why a module is not here: GET /store/modules/<key>/listing');
|
|
1132
|
+
return out.join('\n');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
|
|
1136
|
+
const q = storeQuery(args);
|
|
1137
|
+
if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
|
|
1138
|
+
let call;
|
|
1139
|
+
try { call = request || storeRequest(); }
|
|
1140
|
+
catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
|
|
1141
|
+
let res;
|
|
1142
|
+
try { res = await call('GET', `/store/modules${q.query}`); }
|
|
1143
|
+
catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
|
|
1144
|
+
if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
|
|
1145
|
+
if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
|
|
1146
|
+
log(formatStore(res.data || {}));
|
|
1147
|
+
return 0;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1063
1150
|
// Where install places a module: the INSTANCE's own modules/ root (ADR 0108 §1),
|
|
1064
1151
|
// which the loader discovers. In a single checkout that is this repo's modules/.
|
|
1065
1152
|
function instanceModulesDir() {
|
|
@@ -1319,6 +1406,8 @@ function printHelp(out = console.log) {
|
|
|
1319
1406
|
out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
|
|
1320
1407
|
out(' bongos module list [--catalog] [--search <text>] [--json]');
|
|
1321
1408
|
out(' Browse the module catalog — what you can enable, with author + origin credit');
|
|
1409
|
+
out(' bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]');
|
|
1410
|
+
out(' What the store lists, best first: above the quality floor, ranked, ten per sub-category');
|
|
1322
1411
|
out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
|
|
1323
1412
|
out(' then list newer store versions of installed modules (never blocks)');
|
|
1324
1413
|
out(' bongos module check <key> [--sign-off "Name <email>"]');
|
|
@@ -1338,6 +1427,7 @@ async function main(argv) {
|
|
|
1338
1427
|
const sub = args[0];
|
|
1339
1428
|
if (sub === 'new') return cmdNew(args.slice(1));
|
|
1340
1429
|
if (sub === 'list' || sub === 'ls') return cmdList(args.slice(1));
|
|
1430
|
+
if (sub === 'store') return cmdStore(args.slice(1));
|
|
1341
1431
|
if (sub === 'upgrade') return cmdUpgrade(args.slice(1));
|
|
1342
1432
|
if (sub === 'check') return cmdCheck(args.slice(1));
|
|
1343
1433
|
if (sub === 'submit') return cmdSubmit(args.slice(1));
|
|
@@ -1354,7 +1444,7 @@ async function main(argv) {
|
|
|
1354
1444
|
}
|
|
1355
1445
|
|
|
1356
1446
|
module.exports = {
|
|
1357
|
-
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1447
|
+
main, cmdNew, cmdList, cmdStore, formatStore, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1358
1448
|
storeRequest,
|
|
1359
1449
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
1360
1450
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// A delisted module (ADR 0338 D2) is never in the catalog, whatever its score.
|
|
18
18
|
|
|
19
19
|
const { parseVersion, cmp } = require('../module-loader/semver');
|
|
20
|
+
const { readModuleCategories } = require('./module-subcategories');
|
|
20
21
|
|
|
21
22
|
// The quality floor, 0-100. It lives here and nowhere else: it is part of Bongos
|
|
22
23
|
// itself, not a live setting, so no author, builder or instance can move it.
|
|
@@ -106,9 +107,9 @@ function readParts(signals = [], { floor = QUALITY_FLOOR } = {}) {
|
|
|
106
107
|
// last, in key order: a missing part is never a zero, and never a first place.
|
|
107
108
|
//
|
|
108
109
|
// Only `listed` is sorted. A "New" version is never ranked (owner decision,
|
|
109
|
-
// 2026-10-01), so `new` keeps key order whatever the buyer picks.
|
|
110
|
-
//
|
|
111
|
-
//
|
|
110
|
+
// 2026-10-01), so `new` keeps key order whatever the buyer picks. The cap of ten
|
|
111
|
+
// per approved sub-category (applyCap) is drawn from the default ranking before any
|
|
112
|
+
// sort, so a buyer's sort never changes who is listed.
|
|
112
113
|
const SORT_PARTS = PARTS.map(({ part, title }) => ({ part, title }));
|
|
113
114
|
|
|
114
115
|
// `entries` carry `parts` as readParts builds them. Returns a new array. Pure.
|
|
@@ -174,6 +175,44 @@ function rankListed(entries) {
|
|
|
174
175
|
});
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// The cap: at most ten listed per sub-category (task 1003809, ADR 0342 D2)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
//
|
|
182
|
+
// The owner's rule: fifteen Stripe modules all scoring above 94% still show only the
|
|
183
|
+
// top ten. The comparable set is an APPROVED sub-category, never a parent (D2): a
|
|
184
|
+
// module whose sub-category is only proposed is listed under its parent and counts
|
|
185
|
+
// toward no cap (D3). The ten are the first ten in the default ranking (RANKING), so
|
|
186
|
+
// a buyer's sort or maximum price reorders or narrows the ten, never changes who they
|
|
187
|
+
// are. A New version is not ranked, so it neither takes a slot nor is held by one.
|
|
188
|
+
const CAP_PER_SUBCATEGORY = 10;
|
|
189
|
+
|
|
190
|
+
// `ranked` in rankListed order; `cats` a Map of key -> readModuleCategories entry.
|
|
191
|
+
// Returns { kept, held }: `kept` keeps the order, `held` maps each key past the cap
|
|
192
|
+
// to { rank, cap, label } (its place in its sub-category). Pure.
|
|
193
|
+
function applyCap(ranked, cats, cap = CAP_PER_SUBCATEGORY) {
|
|
194
|
+
const seen = new Map();
|
|
195
|
+
const kept = [];
|
|
196
|
+
const held = new Map();
|
|
197
|
+
for (const e of ranked) {
|
|
198
|
+
const c = cats.get(e.key);
|
|
199
|
+
if (!c || !c.live) { kept.push(e); continue; }
|
|
200
|
+
const set = `${c.parent}/${c.sub}`;
|
|
201
|
+
const rank = (seen.get(set) || 0) + 1;
|
|
202
|
+
seen.set(set, rank);
|
|
203
|
+
if (rank <= cap) kept.push(e);
|
|
204
|
+
else held.set(e.key, { rank, cap, label: c.label || c.sub });
|
|
205
|
+
}
|
|
206
|
+
return { kept, held };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Where a module sits, as the catalog shows it: its parent, and its sub-category
|
|
210
|
+
// only once approved. A proposed one is not shown: it is not live yet (D3).
|
|
211
|
+
function categoryOf(c) {
|
|
212
|
+
if (!c) return null;
|
|
213
|
+
return { parent: c.parent, parent_title: c.parent_title, sub: c.live ? c.sub : null, label: c.live ? c.label : null };
|
|
214
|
+
}
|
|
215
|
+
|
|
177
216
|
// The newest version row per module key, in semver order (a version that does not
|
|
178
217
|
// parse is skipped, as latestVersions does in module-store.js).
|
|
179
218
|
function newestPerModule(rows) {
|
|
@@ -188,19 +227,28 @@ function newestPerModule(rows) {
|
|
|
188
227
|
return [...out.values()];
|
|
189
228
|
}
|
|
190
229
|
|
|
191
|
-
// The catalog: { floor, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
|
|
192
|
-
// each entry { key, title, version, published_at, price, overall, is_new,
|
|
193
|
-
// strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
|
|
194
|
-
// versions are left out entirely: a module below the bar is not listed at all,
|
|
195
|
-
//
|
|
196
|
-
// `sort` names a part, which orders it by
|
|
197
|
-
// stays in key order.
|
|
198
|
-
async function listCatalog(
|
|
230
|
+
// The catalog: { floor, cap, ranking, sort, sort_parts, max_price, listed: [...], new: [...] },
|
|
231
|
+
// each entry { key, title, version, published_at, price, category, overall, is_new,
|
|
232
|
+
// parts, strengths, weaknesses, unmeasured } (the last four from readParts). Excluded
|
|
233
|
+
// versions are left out entirely: a module below the bar is not listed at all, nor
|
|
234
|
+
// is one past the cap of its sub-category, nor one over the buyer's `maxPrice`.
|
|
235
|
+
// `listed` is in rankListed order unless `sort` names a part, which orders it by
|
|
236
|
+
// sortByPart; `new` is never ranked and stays in key order.
|
|
237
|
+
async function listCatalog(opts = {}) {
|
|
238
|
+
return (await buildCatalog(opts)).catalog;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// listCatalog's work, plus `held`: the keys the cap left out, with their place
|
|
242
|
+
// (applyCap). readListing reads `held` to tell an author they are eleventh, and
|
|
243
|
+
// passes `keys` — one sub-category's modules — so one module's check reads only
|
|
244
|
+
// the set it competes in, never the whole store. The cap counts within a
|
|
245
|
+
// sub-category, so that subset ranks exactly as it does in the full catalog.
|
|
246
|
+
async function buildCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice = null, keys = null } = {}) {
|
|
199
247
|
const pool = db || require('./pool').pool;
|
|
200
248
|
const { rows: versions } = await pool.query(
|
|
201
249
|
`SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
|
|
202
250
|
FROM store_modules m JOIN store_module_versions v ON v.module_key = m.module_key
|
|
203
|
-
WHERE m.status = 'listed'
|
|
251
|
+
WHERE m.status = 'listed'${keys ? ' AND m.module_key = ANY($1)' : ''}`, keys ? [keys] : []);
|
|
204
252
|
const newest = newestPerModule(versions);
|
|
205
253
|
const scores = new Map();
|
|
206
254
|
if (newest.length) {
|
|
@@ -237,21 +285,26 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
|
|
|
237
285
|
}
|
|
238
286
|
const max = maxPrice && (maxPrice.cents != null || maxPrice.credits != null)
|
|
239
287
|
? { cents: maxPrice.cents ?? null, credits: maxPrice.credits ?? null } : null;
|
|
240
|
-
const
|
|
288
|
+
const cats = await readModuleCategories(newest.map((r) => r.module_key), { db: pool });
|
|
289
|
+
const out = { floor, cap: CAP_PER_SUBCATEGORY, ranking: RANKING, sort: sort || null, sort_parts: SORT_PARTS, max_price: max, listed: [], new: [] };
|
|
241
290
|
for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
|
|
242
291
|
const score = scores.get(String(r.version_id)) || null;
|
|
243
292
|
const { placement } = placeVersion(score, floor);
|
|
244
293
|
if (placement === 'excluded') continue;
|
|
245
|
-
const price = priceOf(prices.get(r.module_key));
|
|
246
|
-
if (!withinMaxPrice(price, max)) continue;
|
|
247
294
|
out[placement].push({
|
|
248
|
-
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
295
|
+
key: r.module_key, title: r.title, version: r.version, published_at: r.published_at,
|
|
296
|
+
price: priceOf(prices.get(r.module_key)), category: categoryOf(cats.get(r.module_key)),
|
|
249
297
|
overall: placement === 'new' ? null : score.overall, is_new: placement === 'new',
|
|
250
298
|
...readParts(signals.get(String(r.version_id)) || [], { floor }),
|
|
251
299
|
});
|
|
252
300
|
}
|
|
253
|
-
|
|
254
|
-
|
|
301
|
+
// The cap first, over the default ranking; then the buyer's filter and order.
|
|
302
|
+
const { kept, held } = applyCap(rankListed(out.listed), cats);
|
|
303
|
+
const affordable = (e) => withinMaxPrice(e.price, max);
|
|
304
|
+
out.listed = kept.filter(affordable);
|
|
305
|
+
out.new = out.new.filter(affordable);
|
|
306
|
+
if (sort) out.listed = sortByPart(out.listed, sort);
|
|
307
|
+
return { catalog: out, held };
|
|
255
308
|
}
|
|
256
309
|
|
|
257
310
|
// ---------------------------------------------------------------------------
|
|
@@ -263,9 +316,10 @@ async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null, maxPrice =
|
|
|
263
316
|
// names the reason in a sentence, says what would change it, and carries the same
|
|
264
317
|
// parts reading the catalog shows (the weakest parts first). The reasons are
|
|
265
318
|
// placeVersion's, plus two the catalog drops before it gets that far: `delisted`
|
|
266
|
-
// and `no_version
|
|
267
|
-
//
|
|
268
|
-
|
|
319
|
+
// and `no_version`, and `over_cap`: above the floor but past the top ten of its
|
|
320
|
+
// sub-category (task 1003809). From outside, being eleventh looks the same as being
|
|
321
|
+
// below the floor, so the author is told which it is.
|
|
322
|
+
function explainPlacement({ placement, reason }, { overall = null, floor = QUALITY_FLOOR, security = null, weaknesses = [], held = null } = {}) {
|
|
269
323
|
const weakest = weaknesses.slice(0, 3).map((w) => `${w.title} (${w.score})`).join(', ');
|
|
270
324
|
const say = (why, nextStep) => ({ listed: placement !== 'excluded', placement, reason, why, next_step: nextStep });
|
|
271
325
|
switch (reason) {
|
|
@@ -291,6 +345,9 @@ function explainPlacement({ placement, reason }, { overall = null, floor = QUALI
|
|
|
291
345
|
case 'below_floor':
|
|
292
346
|
return say(`This version scored ${overall} out of 100. The store lists modules that score at least ${floor}.${weakest ? ` Its weakest parts: ${weakest}.` : ''}`,
|
|
293
347
|
'Improve its weakest parts and publish a new version. The newest version is the one judged.');
|
|
348
|
+
case 'over_cap':
|
|
349
|
+
return say(`This version scored ${overall} out of 100, above the floor of ${floor}, but it ranks ${held.rank} in ${held.label}. The store lists the top ${held.cap} in each sub-category.`,
|
|
350
|
+
`Score higher than the module in place ${held.cap} and publish a new version. Between equal scores, the cheaper module ranks higher.`);
|
|
294
351
|
case 'new':
|
|
295
352
|
return say('This version is listed with a New label. It is not ranked until it has real install and reliability data.',
|
|
296
353
|
'Nothing to do: it is ranked once projects have installed and used it.');
|
|
@@ -301,7 +358,7 @@ function explainPlacement({ placement, reason }, { overall = null, floor = QUALI
|
|
|
301
358
|
}
|
|
302
359
|
|
|
303
360
|
// Where one module stands in the catalog, and why: null for a key the store does not
|
|
304
|
-
// know, else { module_key, title, status, version, version_id, floor, overall,
|
|
361
|
+
// know, else { module_key, title, status, category, version, version_id, floor, overall,
|
|
305
362
|
// listed, placement, reason, why, next_step, security, parts, strengths, weaknesses,
|
|
306
363
|
// unmeasured }. Judges the same version the catalog does: the newest published one.
|
|
307
364
|
// What the Security gate found is told only to the module's author (`viewerId`): to
|
|
@@ -312,7 +369,8 @@ async function readListing(key, { db, floor = QUALITY_FLOOR, viewerId = null } =
|
|
|
312
369
|
'SELECT module_key, title, status, author_id FROM store_modules WHERE module_key = $1', [key]);
|
|
313
370
|
const mod = mods[0];
|
|
314
371
|
if (!mod) return null;
|
|
315
|
-
const
|
|
372
|
+
const cat = (await readModuleCategories([key], { db: pool })).get(key);
|
|
373
|
+
const head = { module_key: mod.module_key, title: mod.title, status: mod.status, floor, category: categoryOf(cat) };
|
|
316
374
|
const { rows: versions } = await pool.query(
|
|
317
375
|
'SELECT module_key, id AS version_id, version, published_at FROM store_module_versions WHERE module_key = $1', [key]);
|
|
318
376
|
const newest = newestPerModule(versions)[0];
|
|
@@ -337,16 +395,30 @@ async function readListing(key, { db, floor = QUALITY_FLOOR, viewerId = null } =
|
|
|
337
395
|
const isAuthor = viewerId != null && String(mod.author_id) === String(viewerId);
|
|
338
396
|
const security = sec ? { outcome: sec.outcome, reason: (isAuthor && sec.reason) || null } : null;
|
|
339
397
|
const reading = readParts(signals, { floor });
|
|
340
|
-
|
|
398
|
+
let placed = placeVersion(score, floor);
|
|
399
|
+
// Above the floor in a live sub-category, the cap decides, over the same ranking
|
|
400
|
+
// the catalog uses, so the two never disagree. Only that sub-category is read:
|
|
401
|
+
// `self` is found by store_module_categories' primary key (module_key) and `peer`
|
|
402
|
+
// by idx_store_module_categories_sub (subcategory_id), both in core_280.
|
|
403
|
+
let held = null;
|
|
404
|
+
if (placed.placement === 'listed' && cat && cat.live) {
|
|
405
|
+
const { rows: peers } = await pool.query(
|
|
406
|
+
`SELECT peer.module_key
|
|
407
|
+
FROM store_module_categories self
|
|
408
|
+
JOIN store_module_categories peer ON peer.subcategory_id = self.subcategory_id
|
|
409
|
+
WHERE self.module_key = $1`, [key]);
|
|
410
|
+
held = (await buildCatalog({ db: pool, floor, keys: peers.map((p) => p.module_key) })).held.get(key) || null;
|
|
411
|
+
if (held) placed = { placement: 'excluded', reason: 'over_cap' };
|
|
412
|
+
}
|
|
341
413
|
const overall = score && placed.placement !== 'new' ? score.overall : null;
|
|
342
414
|
return {
|
|
343
415
|
...head, version: newest.version, version_id: newest.version_id, overall,
|
|
344
|
-
...explainPlacement(placed, { overall, floor, security, weaknesses: reading.weaknesses }),
|
|
416
|
+
...explainPlacement(placed, { overall, floor, security, weaknesses: reading.weaknesses, held }),
|
|
345
417
|
security, ...reading,
|
|
346
418
|
};
|
|
347
419
|
}
|
|
348
420
|
|
|
349
421
|
module.exports = {
|
|
350
|
-
QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, placeVersion, readParts,
|
|
351
|
-
priceOf, withinMaxPrice, rankListed, listCatalog, explainPlacement, readListing,
|
|
422
|
+
QUALITY_FLOOR, STRONG_AT, SORT_PARTS, RANKING, CAP_PER_SUBCATEGORY, placeVersion, readParts,
|
|
423
|
+
sortByPart, priceOf, withinMaxPrice, rankListed, applyCap, listCatalog, explainPlacement, readListing,
|
|
352
424
|
};
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// tests/module_store_cli.mjs — `bongos module store` and the hall read ONE catalog
|
|
2
|
+
// (task 1003811, criterion wa5-curated-listing). The terminal command asks the
|
|
3
|
+
// real GET /store/modules handler here — off the real router, with pool.js replaced
|
|
4
|
+
// in the require cache (the module_store_listing.mjs harness) — so the test pins
|
|
5
|
+
// what the task asks: the same query through the API and through the CLI returns
|
|
6
|
+
// the same modules in the same order, capped the same way. Two surfaces that drift
|
|
7
|
+
// on which modules are "the best" is worse than either being wrong alone.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/module_store_cli.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
// ---- the fake registry the route reads ----
|
|
18
|
+
const state = { modules: {}, versions: [], scores: [], prices: [], categories: {} };
|
|
19
|
+
const newestBy = (rows, keyOf, newer) => {
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const r of rows) {
|
|
22
|
+
const k = keyOf(r);
|
|
23
|
+
if (!out.has(k) || newer(r, out.get(k))) out.set(k, r);
|
|
24
|
+
}
|
|
25
|
+
return [...out.values()];
|
|
26
|
+
};
|
|
27
|
+
const pool = {
|
|
28
|
+
async query(sql, params = []) {
|
|
29
|
+
if (/FROM store_module_categories c\b/.test(sql)) {
|
|
30
|
+
return { rows: params[0].filter((k) => state.categories[k]).map((k) => ({ module_key: k, ...state.categories[k] })) };
|
|
31
|
+
}
|
|
32
|
+
if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
|
|
33
|
+
return {
|
|
34
|
+
rows: state.versions
|
|
35
|
+
.filter((v) => state.modules[v.module_key] && state.modules[v.module_key].status === 'listed')
|
|
36
|
+
.filter((v) => !params[0] || params[0].includes(v.module_key))
|
|
37
|
+
.map((v) => ({ ...v, version_id: v.id, title: state.modules[v.module_key].title })),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (/FROM store_module_prices/.test(sql)) {
|
|
41
|
+
return { rows: newestBy(state.prices.filter((p) => params[0].includes(p.module_key)), (p) => p.module_key, (a, b) => a.set_at > b.set_at) };
|
|
42
|
+
}
|
|
43
|
+
if (/FROM module_assessment_scores/.test(sql)) {
|
|
44
|
+
const ids = params[0].map(String);
|
|
45
|
+
return { rows: newestBy(state.scores.filter((s) => ids.includes(String(s.version_id))), (s) => s.version_id, (a, b) => a.computed_at > b.computed_at) };
|
|
46
|
+
}
|
|
47
|
+
return { rows: [] };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
51
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
52
|
+
|
|
53
|
+
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
54
|
+
const mod = require('../scripts/bongos/module.js');
|
|
55
|
+
|
|
56
|
+
function storeHandler() {
|
|
57
|
+
const layer = buildModulesRouter().stack.find((l) => l.route && l.route.path === '/store/modules' && l.route.methods.get);
|
|
58
|
+
const stack = layer.route.stack;
|
|
59
|
+
return stack[stack.length - 1].handle;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The API, as a browser or the hall reads it.
|
|
63
|
+
async function apiGet(query) {
|
|
64
|
+
const res = {
|
|
65
|
+
statusCode: 200, body: undefined,
|
|
66
|
+
json(b) { this.body = b; return this; },
|
|
67
|
+
status(c) { this.statusCode = c; return this; },
|
|
68
|
+
set() { return this; },
|
|
69
|
+
fail(code, opts) { this.statusCode = opts.status; this.body = { error: { code, message: opts.message } }; return this; },
|
|
70
|
+
};
|
|
71
|
+
let nextErr = null;
|
|
72
|
+
await storeHandler()({ builder: { id: 0 }, query }, res, (err) => { nextErr = err; });
|
|
73
|
+
if (nextErr) throw nextErr;
|
|
74
|
+
return res;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The CLI's request function, answered by the same handler: the URL the CLI
|
|
78
|
+
// builds is parsed back into the query the route reads.
|
|
79
|
+
const request = async (method, urlPath) => {
|
|
80
|
+
assert.equal(method, 'GET');
|
|
81
|
+
const url = new URL(urlPath, 'http://store.test');
|
|
82
|
+
assert.equal(url.pathname, '/store/modules');
|
|
83
|
+
const res = await apiGet(Object.fromEntries(url.searchParams));
|
|
84
|
+
return { ok: res.statusCode < 400, status: res.statusCode, data: res.body };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
async function cliJson(args) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
const errs = [];
|
|
90
|
+
const code = await mod.cmdStore([...args, '--json'], { log: (l) => lines.push(l), errlog: (l) => errs.push(l), request });
|
|
91
|
+
return { code, body: lines.length ? JSON.parse(lines.join('\n')) : null, errs };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const keys = (b) => b.listed.map((m) => m.key);
|
|
95
|
+
|
|
96
|
+
// Fifteen Stripe modules above 94 (the owner's example), one module in a proposed
|
|
97
|
+
// sub-category, one with no category, one New, and one under the floor.
|
|
98
|
+
function seed() {
|
|
99
|
+
state.modules = {}; state.versions = []; state.scores = []; state.prices = []; state.categories = {};
|
|
100
|
+
const add = (key, s, cat) => {
|
|
101
|
+
const id = state.versions.length + 1;
|
|
102
|
+
state.modules[key] = { status: 'listed', title: key };
|
|
103
|
+
state.versions.push({ id, module_key: key, version: '1.0.0', published_at: 't' });
|
|
104
|
+
state.scores.push({ id, version_id: id, computed_at: 1, overall: 70, security_passed: true, is_new: false, ...s });
|
|
105
|
+
if (cat) state.categories[key] = cat;
|
|
106
|
+
};
|
|
107
|
+
const stripe = { parent: 'money', sub_key: 'stripe-payments', label: 'Stripe payments', status: 'approved' };
|
|
108
|
+
for (let i = 0; i < 15; i++) add(`stripe-${String(i).padStart(2, '0')}`, { overall: 99 - i }, stripe);
|
|
109
|
+
add('waiting', { overall: 60 }, { parent: 'money', sub_key: 'stripe-billing', label: 'Stripe billing', status: 'proposed' });
|
|
110
|
+
add('loose', { overall: 88 });
|
|
111
|
+
add('fresh', { is_new: true, overall: null });
|
|
112
|
+
add('weak', { overall: 20 });
|
|
113
|
+
state.prices = [
|
|
114
|
+
{ module_key: 'stripe-01', price_cents: 900, price_credits: null, set_at: 1 },
|
|
115
|
+
{ module_key: 'loose', price_cents: null, price_credits: 40, set_at: 1 },
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test('the terminal shows what the API lists, in its order, capped the same way', async () => {
|
|
120
|
+
seed();
|
|
121
|
+
const api = (await apiGet({})).body;
|
|
122
|
+
const cli = await cliJson([]);
|
|
123
|
+
assert.equal(cli.code, 0);
|
|
124
|
+
assert.deepEqual(cli.body, api, 'the CLI hands back the catalog exactly as the API answers it');
|
|
125
|
+
assert.equal(keys(api).filter((k) => k.startsWith('stripe-')).length, 10, 'the cap of ten holds');
|
|
126
|
+
assert.ok(!keys(api).includes('stripe-10') && !keys(api).includes('weak'), 'the eleventh and the one under the floor are not listed');
|
|
127
|
+
assert.ok(keys(api).includes('waiting') && keys(api).includes('loose'), 'a proposed sub-category and no category are not capped');
|
|
128
|
+
assert.deepEqual(api.new.map((m) => m.key), ['fresh']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the same query through both: a sort and a maximum price give the same modules in the same order', async () => {
|
|
132
|
+
seed();
|
|
133
|
+
for (const [args, query] of [
|
|
134
|
+
[['--sort', 'tests'], { sort: 'tests' }],
|
|
135
|
+
[['--max-cents', '500'], { max_cents: '500' }],
|
|
136
|
+
[['--max-credits', '0'], { max_credits: '0' }],
|
|
137
|
+
[['--sort', 'docs', '--max-cents', '1000', '--max-credits', '100'], { sort: 'docs', max_cents: '1000', max_credits: '100' }],
|
|
138
|
+
]) {
|
|
139
|
+
const api = (await apiGet(query)).body;
|
|
140
|
+
const cli = await cliJson(args);
|
|
141
|
+
assert.equal(cli.code, 0, args.join(' '));
|
|
142
|
+
assert.deepEqual(keys(cli.body), keys(api), args.join(' '));
|
|
143
|
+
assert.deepEqual(cli.body.new.map((m) => m.key), api.new.map((m) => m.key), args.join(' '));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the text view keeps the API order: listed ranked, then New, and says the floor and the cap', async () => {
|
|
148
|
+
seed();
|
|
149
|
+
const api = (await apiGet({})).body;
|
|
150
|
+
const lines = [];
|
|
151
|
+
assert.equal(await mod.cmdStore([], { log: (l) => lines.push(l), errlog: () => {}, request }), 0);
|
|
152
|
+
const text = lines.join('\n');
|
|
153
|
+
assert.match(text, /floor 50 out of 100 · at most 10 per sub-category/);
|
|
154
|
+
const at = (k) => text.indexOf(`${k}@`);
|
|
155
|
+
const order = [...keys(api), ...api.new.map((m) => m.key)].map(at);
|
|
156
|
+
assert.ok(order.every((i) => i !== -1), 'every listed and New module is shown');
|
|
157
|
+
assert.deepEqual(order, [...order].sort((a, b) => a - b), 'in the order the API gave');
|
|
158
|
+
assert.equal(at('stripe-10'), -1, 'the eleventh is not shown');
|
|
159
|
+
assert.match(text, /stripe-00@1\.0\.0 {2}99\/100 {2}free {2}Money › Stripe payments/);
|
|
160
|
+
assert.match(text, /stripe-01@1\.0\.0 {2}98\/100 {2}\$9\.00/);
|
|
161
|
+
assert.match(text, /loose@1\.0\.0 {2}88\/100 {2}40 credits$/m, 'no category, no place');
|
|
162
|
+
assert.match(text, /waiting@1\.0\.0 {2}60\/100 {2}free {2}Money$/m, 'a proposed sub-category shows its parent only');
|
|
163
|
+
assert.match(text, /New — not ranked[\s\S]*fresh@1\.0\.0/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a value the server refuses, a flag with no value, and no session are reported, not swallowed', async () => {
|
|
167
|
+
seed();
|
|
168
|
+
const bad = await cliJson(['--sort', 'security']);
|
|
169
|
+
assert.equal(bad.code, 2);
|
|
170
|
+
assert.match(bad.errs.join('\n'), /the store answered/);
|
|
171
|
+
const missing = await cliJson(['--max-cents']);
|
|
172
|
+
assert.equal(missing.code, 2);
|
|
173
|
+
assert.match(missing.errs.join('\n'), /--max-cents needs a value/);
|
|
174
|
+
const errs = [];
|
|
175
|
+
const offline = await mod.cmdStore([], { log: () => {}, errlog: (l) => errs.push(l), request: async () => { throw new Error('ECONNREFUSED'); } });
|
|
176
|
+
assert.equal(offline, 1);
|
|
177
|
+
assert.match(errs.join('\n'), /could not be reached — ECONNREFUSED/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('`bongos module store` is dispatched and documented in the help', async () => {
|
|
181
|
+
const out = [];
|
|
182
|
+
mod.printHelp((l) => out.push(l));
|
|
183
|
+
assert.match(out.join('\n'), /bongos module store \[--sort <part>\]/);
|
|
184
|
+
});
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// - the route: the real GET /store/modules handler off the real router, with
|
|
7
7
|
// pool.js replaced in the require cache (the module_upgrade_updates.mjs harness).
|
|
8
8
|
//
|
|
9
|
+
// The cap of ten per approved sub-category (task 1003809) rides the same harness.
|
|
10
|
+
//
|
|
9
11
|
// Run: node tests/module_store_listing.mjs
|
|
10
12
|
|
|
11
13
|
import { strict as assert } from 'node:assert';
|
|
@@ -15,7 +17,7 @@ import { createRequire } from 'node:module';
|
|
|
15
17
|
const require = createRequire(import.meta.url);
|
|
16
18
|
|
|
17
19
|
// ---- the fake registry the route reads ----
|
|
18
|
-
const state = { modules: {}, versions: [], scores: [], signals: [], prices: [] };
|
|
20
|
+
const state = { modules: {}, versions: [], scores: [], signals: [], prices: [], categories: {}, versionReads: [] };
|
|
19
21
|
const pool = {
|
|
20
22
|
async query(sql, params = []) {
|
|
21
23
|
if (/FROM store_module_prices/.test(sql)) {
|
|
@@ -27,10 +29,27 @@ const pool = {
|
|
|
27
29
|
}
|
|
28
30
|
return { rows: [...newest.values()] };
|
|
29
31
|
}
|
|
32
|
+
if (/FROM store_module_categories self/.test(sql)) {
|
|
33
|
+
state.peerReads = (state.peerReads || 0) + 1;
|
|
34
|
+
const mine = state.categories[params[0]];
|
|
35
|
+
return {
|
|
36
|
+
rows: Object.keys(state.categories)
|
|
37
|
+
.filter((k) => mine && state.categories[k].sub_key === mine.sub_key && state.categories[k].parent === mine.parent)
|
|
38
|
+
.map((k) => ({ module_key: k })),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (/FROM store_module_categories c\b/.test(sql)) {
|
|
42
|
+
return {
|
|
43
|
+
rows: params[0].filter((k) => state.categories[k])
|
|
44
|
+
.map((k) => ({ module_key: k, sub_key: null, label: null, status: null, ...state.categories[k] })),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
30
47
|
if (/FROM store_modules m JOIN store_module_versions v/.test(sql)) {
|
|
48
|
+
state.versionReads.push(params[0] ? [...params[0]] : null);
|
|
31
49
|
return {
|
|
32
50
|
rows: state.versions
|
|
33
51
|
.filter((v) => state.modules[v.module_key] && state.modules[v.module_key].status === 'listed')
|
|
52
|
+
.filter((v) => !params[0] || params[0].includes(v.module_key))
|
|
34
53
|
.map((v) => ({ ...v, version_id: v.id, title: state.modules[v.module_key].title })),
|
|
35
54
|
};
|
|
36
55
|
}
|
|
@@ -69,7 +88,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
|
|
|
69
88
|
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
70
89
|
|
|
71
90
|
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
72
|
-
const { QUALITY_FLOOR, SORT_PARTS, RANKING, placeVersion, sortByPart, priceOf, withinMaxPrice, rankListed, explainPlacement } = require('../src/bongos/module-listing.js');
|
|
91
|
+
const { QUALITY_FLOOR, SORT_PARTS, RANKING, CAP_PER_SUBCATEGORY, placeVersion, sortByPart, priceOf, withinMaxPrice, rankListed, applyCap, explainPlacement } = require('../src/bongos/module-listing.js');
|
|
73
92
|
|
|
74
93
|
function handlerFor(router, method, p) {
|
|
75
94
|
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
@@ -179,7 +198,7 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
|
|
|
179
198
|
state.modules = {}; state.versions = []; state.scores = [];
|
|
180
199
|
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
181
200
|
const res = await invoke(handle, {});
|
|
182
|
-
assert.deepEqual(res.body, { floor: 50, ranking: RANKING, sort: null, sort_parts: SORT_PARTS, max_price: null, listed: [], new: [] });
|
|
201
|
+
assert.deepEqual(res.body, { floor: 50, cap: 10, ranking: RANKING, sort: null, sort_parts: SORT_PARTS, max_price: null, listed: [], new: [] });
|
|
183
202
|
});
|
|
184
203
|
|
|
185
204
|
// ---------------------------------------------------------------------------
|
|
@@ -430,6 +449,7 @@ test('explainPlacement: every reason says why in a sentence and what would chang
|
|
|
430
449
|
[{ placement: 'excluded', reason: 'delisted' }, {}],
|
|
431
450
|
[{ placement: 'excluded', reason: 'no_version' }, {}],
|
|
432
451
|
[{ placement: 'new', reason: 'new' }, {}],
|
|
452
|
+
[{ placement: 'excluded', reason: 'over_cap' }, { overall: 95, held: { rank: 11, cap: 10, label: 'Stripe payments' } }],
|
|
433
453
|
]) {
|
|
434
454
|
const e = explainPlacement(placed, ctx);
|
|
435
455
|
assert.equal(e.reason, placed.reason);
|
|
@@ -544,3 +564,112 @@ test('the listing read and the catalog agree on every module', async () => {
|
|
|
544
564
|
assert.equal(r.body.listed, shown.has(key), key);
|
|
545
565
|
}
|
|
546
566
|
});
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// the cap: at most ten per approved sub-category (task 1003809, ADR 0342 D2/D3)
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
const live = (parent, sub, label) => ({ parent, parent_title: parent, sub, label, status: 'approved', live: true });
|
|
573
|
+
|
|
574
|
+
test('the cap is ten (owner decision, 2026-09-10)', () => {
|
|
575
|
+
assert.equal(CAP_PER_SUBCATEGORY, 10);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test('applyCap: ten per approved sub-category, counted in ranking order; a proposed one or none is never capped', () => {
|
|
579
|
+
const ranked = Array.from({ length: 12 }, (_, i) => ({ key: `s${String(i).padStart(2, '0')}` }))
|
|
580
|
+
.concat([{ key: 'other' }, { key: 'proposed' }, { key: 'loose' }]);
|
|
581
|
+
const cats = new Map(ranked.slice(0, 12).map((e) => [e.key, live('money', 'stripe', 'Stripe payments')]));
|
|
582
|
+
cats.set('other', live('money', 'invoices', 'Invoices'));
|
|
583
|
+
cats.set('proposed', { parent: 'money', sub: 'stripe', label: 'Stripe', status: 'proposed', live: false });
|
|
584
|
+
const { kept, held } = applyCap(ranked, cats);
|
|
585
|
+
assert.deepEqual(kept.map((e) => e.key), [...ranked.slice(0, 10).map((e) => e.key), 'other', 'proposed', 'loose']);
|
|
586
|
+
assert.deepEqual(held.get('s10'), { rank: 11, cap: 10, label: 'Stripe payments' });
|
|
587
|
+
assert.deepEqual(held.get('s11'), { rank: 12, cap: 10, label: 'Stripe payments' });
|
|
588
|
+
assert.equal(held.size, 2);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('applyCap: the same sub key under two parents is two comparable sets', () => {
|
|
592
|
+
const ranked = Array.from({ length: 11 }, (_, i) => ({ key: `a${i}` })).concat(Array.from({ length: 2 }, (_, i) => ({ key: `b${i}` })));
|
|
593
|
+
const cats = new Map(ranked.map((e) => [e.key, e.key.startsWith('a') ? live('money', 'general', 'General') : live('work', 'general', 'General')]));
|
|
594
|
+
const { held } = applyCap(ranked, cats);
|
|
595
|
+
assert.deepEqual([...held.keys()], ['a10']);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// The owner's example, verbatim: fifteen Stripe modules all scoring above 94% still
|
|
599
|
+
// show only the top ten.
|
|
600
|
+
function fifteenStripeModules() {
|
|
601
|
+
state.versionReads = [];
|
|
602
|
+
state.modules = {}; state.versions = []; state.scores = []; state.signals = []; state.prices = []; state.categories = {};
|
|
603
|
+
for (let i = 0; i < 15; i++) {
|
|
604
|
+
const key = `stripe-${String(i).padStart(2, '0')}`;
|
|
605
|
+
state.modules[key] = { status: 'listed', title: key };
|
|
606
|
+
state.versions.push({ id: 100 + i, module_key: key, version: '1.0.0', published_at: 't' });
|
|
607
|
+
state.scores.push({ id: 100 + i, version_id: 100 + i, computed_at: 1, ...score({ overall: 99 - i }) });
|
|
608
|
+
state.categories[key] = { parent: 'money', sub_key: 'stripe-payments', label: 'Stripe payments', status: 'approved' };
|
|
609
|
+
// The lowest scorers are best on Tests, so a sort by Tests would put them first.
|
|
610
|
+
state.signals.push({ id: 100 + i, version_id: 100 + i, part: 'tests', outcome: 'scored', score: 50 + i, sample_size: 10, measured_at: 1 });
|
|
611
|
+
}
|
|
612
|
+
state.modules.waiting = { status: 'listed', title: 'Waiting' };
|
|
613
|
+
state.versions.push({ id: 200, module_key: 'waiting', version: '1.0.0', published_at: 't' });
|
|
614
|
+
state.scores.push({ id: 200, version_id: 200, computed_at: 1, ...score({ overall: 60 }) });
|
|
615
|
+
state.categories.waiting = { parent: 'money', sub_key: 'stripe-billing', label: 'Stripe billing', status: 'proposed' };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
test('GET /store/modules: fifteen Stripe modules above 94 show only the top ten; a proposed sub-category is not capped', async () => {
|
|
619
|
+
fifteenStripeModules();
|
|
620
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
621
|
+
const res = await invoke(handle, { query: {} });
|
|
622
|
+
assert.equal(res.body.cap, 10);
|
|
623
|
+
const stripe = res.body.listed.filter((m) => m.category && m.category.sub === 'stripe-payments');
|
|
624
|
+
assert.deepEqual(stripe.map((m) => m.overall), [99, 98, 97, 96, 95, 94, 93, 92, 91, 90]);
|
|
625
|
+
assert.deepEqual(stripe[0].category, { parent: 'money', parent_title: 'Money', sub: 'stripe-payments', label: 'Stripe payments' });
|
|
626
|
+
const waiting = res.body.listed.find((m) => m.key === 'waiting');
|
|
627
|
+
assert.ok(waiting, 'a module in a proposed sub-category is listed under its parent');
|
|
628
|
+
assert.deepEqual(waiting.category, { parent: 'money', parent_title: 'Money', sub: null, label: null }, 'a proposed sub-category is not shown');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('GET /store/modules: a sort or a maximum price reorders or narrows the ten, never lets the eleventh in', async () => {
|
|
632
|
+
fifteenStripeModules();
|
|
633
|
+
state.prices = [{ module_key: 'stripe-00', price_cents: 5000, price_credits: null, set_at: 1 }];
|
|
634
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
635
|
+
const sorted = await invoke(handle, { query: { sort: 'tests' } });
|
|
636
|
+
const keys = (b) => b.listed.filter((m) => m.key.startsWith('stripe-')).map((m) => m.key);
|
|
637
|
+
assert.deepEqual(new Set(keys(sorted.body)), new Set(Array.from({ length: 10 }, (_, i) => `stripe-0${i}`)));
|
|
638
|
+
assert.equal(keys(sorted.body)[0], 'stripe-09', 'the best on Tests among the ten leads');
|
|
639
|
+
const cheap = await invoke(handle, { query: { max_cents: '1000' } });
|
|
640
|
+
assert.equal(keys(cheap.body).length, 9, 'the priced one drops out and nobody takes its slot');
|
|
641
|
+
assert.ok(!keys(cheap.body).includes('stripe-10'));
|
|
642
|
+
state.prices = [];
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test('GET /store/modules/:key/listing: the eleventh is told it is past the cap, not below the floor', async () => {
|
|
646
|
+
fifteenStripeModules();
|
|
647
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules/:key/listing');
|
|
648
|
+
const eleventh = await invoke(handle, { params: { key: 'stripe-10' } });
|
|
649
|
+
assert.equal(eleventh.body.listed, false);
|
|
650
|
+
assert.equal(eleventh.body.reason, 'over_cap');
|
|
651
|
+
assert.equal(eleventh.body.overall, 89);
|
|
652
|
+
assert.match(eleventh.body.why, /scored 89 out of 100, above the floor of 50, but it ranks 11 in Stripe payments/);
|
|
653
|
+
assert.match(eleventh.body.why, /top 10 in each sub-category/);
|
|
654
|
+
assert.ok(eleventh.body.next_step);
|
|
655
|
+
// One module's check reads only its own sub-category, never the whole store.
|
|
656
|
+
const scoped = state.versionReads.filter((r) => r);
|
|
657
|
+
assert.ok(scoped.length, 'the cap check reads a scoped set');
|
|
658
|
+
for (const r of scoped) assert.ok(!r.includes('waiting'), 'a module outside the sub-category is not read');
|
|
659
|
+
assert.equal(state.versionReads.filter((r) => r === null).length, 0, 'the whole store is never read for one module');
|
|
660
|
+
const tenth = await invoke(handle, { params: { key: 'stripe-09' } });
|
|
661
|
+
assert.equal(tenth.body.listed, true);
|
|
662
|
+
assert.equal(tenth.body.reason, 'at_or_above_floor');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('the cap: the listing read and the catalog agree on every module', async () => {
|
|
666
|
+
fifteenStripeModules();
|
|
667
|
+
const catalog = (await invoke(handlerFor(buildModulesRouter(), 'get', '/store/modules').handle, { query: {} })).body;
|
|
668
|
+
const shown = new Set([...catalog.listed, ...catalog.new].map((m) => m.key));
|
|
669
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules/:key/listing');
|
|
670
|
+
for (const key of Object.keys(state.modules)) {
|
|
671
|
+
const r = await invoke(handle, { params: { key } });
|
|
672
|
+
assert.equal(r.body.listed, shown.has(key), key);
|
|
673
|
+
}
|
|
674
|
+
state.categories = {}; state.signals = [];
|
|
675
|
+
});
|