@bongos/core 1.19.964 → 1.19.966
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 +16 -16
- package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +41 -0
- package/docs/adr/README.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/starter-bundles.js +26 -2
- package/modules/public-landing/public/projects.html +17 -4
- package/modules/ui-design/kit/serve.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/projects_hub_module_picker.mjs +79 -4
- package/tests/provisioning_starter_bundles.mjs +25 -1
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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.966",
|
|
6
|
+
"core_contract": "1.19.966",
|
|
7
|
+
"source_commit": "f9a2fd36c3e7b4edf4651b14bdac01a5aaa6d67e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-22T22:38:24.701Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 515,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2905,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "7fa554d04d9424f09eebccfd76a2315c83cd011643b44de050c21147363196a4",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1712,7 +1712,7 @@
|
|
|
1712
1712
|
{
|
|
1713
1713
|
"path": "docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md",
|
|
1714
1714
|
"mode": "0000644",
|
|
1715
|
-
"sha256": "
|
|
1715
|
+
"sha256": "cc2efab0dcf56a590daa249fc9ee177f9f1aed4e7fce9f80e06ff927677c964e"
|
|
1716
1716
|
},
|
|
1717
1717
|
{
|
|
1718
1718
|
"path": "docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md",
|
|
@@ -2127,7 +2127,7 @@
|
|
|
2127
2127
|
{
|
|
2128
2128
|
"path": "docs/adr/README.md",
|
|
2129
2129
|
"mode": "0000644",
|
|
2130
|
-
"sha256": "
|
|
2130
|
+
"sha256": "116fb18808cd324f04600f3c7fd15bb849b0c5c81871db1995f2dcee9bb7e77b"
|
|
2131
2131
|
},
|
|
2132
2132
|
{
|
|
2133
2133
|
"path": "docs/api-reference.md",
|
|
@@ -2697,7 +2697,7 @@
|
|
|
2697
2697
|
{
|
|
2698
2698
|
"path": "docs/module-api-changelog.md",
|
|
2699
2699
|
"mode": "0000644",
|
|
2700
|
-
"sha256": "
|
|
2700
|
+
"sha256": "0620b648ea2eb9cca6c68ff28eade7cae3b27bf3e9d9276ae6c3c08a2d7546d8"
|
|
2701
2701
|
},
|
|
2702
2702
|
{
|
|
2703
2703
|
"path": "docs/modules-contract.md",
|
|
@@ -6787,7 +6787,7 @@
|
|
|
6787
6787
|
{
|
|
6788
6788
|
"path": "modules/provisioning/starter-bundles.js",
|
|
6789
6789
|
"mode": "0000644",
|
|
6790
|
-
"sha256": "
|
|
6790
|
+
"sha256": "afb39d1642004ff98d3b722436e789542b87f0b4929f7d85826e8e07ebbd2570"
|
|
6791
6791
|
},
|
|
6792
6792
|
{
|
|
6793
6793
|
"path": "modules/provisioning/tests/liveness-notify.mjs",
|
|
@@ -7302,7 +7302,7 @@
|
|
|
7302
7302
|
{
|
|
7303
7303
|
"path": "modules/public-landing/public/projects.html",
|
|
7304
7304
|
"mode": "0000644",
|
|
7305
|
-
"sha256": "
|
|
7305
|
+
"sha256": "180097d115d67efd4d5c08119529eb4e9cbafe6e8cae26d5e59c26f910a1434d"
|
|
7306
7306
|
},
|
|
7307
7307
|
{
|
|
7308
7308
|
"path": "modules/public-landing/public/projects.probes.json",
|
|
@@ -7792,7 +7792,7 @@
|
|
|
7792
7792
|
{
|
|
7793
7793
|
"path": "modules/ui-design/kit/serve.js",
|
|
7794
7794
|
"mode": "0000644",
|
|
7795
|
-
"sha256": "
|
|
7795
|
+
"sha256": "871ff3a32396dda09f4aa9127f110821c45b6d616c0636d586cc683db01a111c"
|
|
7796
7796
|
},
|
|
7797
7797
|
{
|
|
7798
7798
|
"path": "modules/ui-design/kit/tells.js",
|
|
@@ -8102,12 +8102,12 @@
|
|
|
8102
8102
|
{
|
|
8103
8103
|
"path": "package-lock.json",
|
|
8104
8104
|
"mode": "0000644",
|
|
8105
|
-
"sha256": "
|
|
8105
|
+
"sha256": "3a5c55425b2f034c483d12ab1884eb78bdd36386c577fc67a877efefd5f284c0"
|
|
8106
8106
|
},
|
|
8107
8107
|
{
|
|
8108
8108
|
"path": "package.json",
|
|
8109
8109
|
"mode": "0000644",
|
|
8110
|
-
"sha256": "
|
|
8110
|
+
"sha256": "bc300eff0b46e3231039eea8ec41caf14b02beec57a09e52735b75ccea9a533e"
|
|
8111
8111
|
},
|
|
8112
8112
|
{
|
|
8113
8113
|
"path": "public-docs/index.html",
|
|
@@ -10112,7 +10112,7 @@
|
|
|
10112
10112
|
{
|
|
10113
10113
|
"path": "src/module-api.js",
|
|
10114
10114
|
"mode": "0000644",
|
|
10115
|
-
"sha256": "
|
|
10115
|
+
"sha256": "0c962d6b334caf231259b5a5b7c394b648916dd7753d5bc518c5e6cd2683cfc0"
|
|
10116
10116
|
},
|
|
10117
10117
|
{
|
|
10118
10118
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13137,7 +13137,7 @@
|
|
|
13137
13137
|
{
|
|
13138
13138
|
"path": "tests/projects_hub_module_picker.mjs",
|
|
13139
13139
|
"mode": "0000644",
|
|
13140
|
-
"sha256": "
|
|
13140
|
+
"sha256": "1a7e888877c7dc7c020a89cd7fda436c373f40b6799866a711d5ce357a34176d"
|
|
13141
13141
|
},
|
|
13142
13142
|
{
|
|
13143
13143
|
"path": "tests/projects_hub_pre_uat.mjs",
|
|
@@ -13322,7 +13322,7 @@
|
|
|
13322
13322
|
{
|
|
13323
13323
|
"path": "tests/provisioning_starter_bundles.mjs",
|
|
13324
13324
|
"mode": "0000644",
|
|
13325
|
-
"sha256": "
|
|
13325
|
+
"sha256": "54c279c84cb5419fd60fa0ae693b617619a8e0a423adee342e01d257236a3d7b"
|
|
13326
13326
|
},
|
|
13327
13327
|
{
|
|
13328
13328
|
"path": "tests/provisioning_teardown_intent.mjs",
|
package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
- **Status:** Accepted
|
|
4
4
|
- **Date:** 2026-09-04
|
|
5
5
|
- **Tasks:** [#1002340](https://cloudbongos.com/builders#/task/1002340) (this decision — BV1.R21)
|
|
6
|
+
- **Amended:** 2026-09-22 by [task 1003500](https://cloudbongos.com/builders#/task/1003500) — §3's "no
|
|
7
|
+
synonyms" is reversed (rows carry declared search `aliases`, so typing `chat` finds Discord); "not
|
|
8
|
+
semantic" stands, the sentence case still finds nothing, and the embeddings layer §3 deferred to
|
|
9
|
+
was declined. See the amendment note in §3.
|
|
6
10
|
- **Goal:** [#1000046](https://cloudbongos.com/builders#/goal/1000046) — Project creation, criterion
|
|
7
11
|
`proj-module-select` ("during setup a default set of modules is pre-selected…")
|
|
8
12
|
- **Related:** [ADR 0237](0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md)
|
|
@@ -78,6 +82,43 @@ Concretely, in `modules/public-landing/public/projects.html`'s picker block:
|
|
|
78
82
|
AI layer for project creation. A lexical filter that admits it is lexical is
|
|
79
83
|
honest; one that half-guesses is not.
|
|
80
84
|
|
|
85
|
+
> **AMENDED 2026-09-22 — "no synonyms" is reversed; "not semantic" stands**
|
|
86
|
+
> ([task 1003500](https://cloudbongos.com/builders#/task/1003500)). The haystack now also
|
|
87
|
+
> carries a row's declared `aliases` (`starter-bundles.js`), so typing **`chat`** finds
|
|
88
|
+
> Discord. Everything else here is unchanged: still AND across terms, still substring,
|
|
89
|
+
> still no ranking or fuzziness, and still nothing that understands a query.
|
|
90
|
+
>
|
|
91
|
+
> **What this does not fix, stated plainly because the example above invites the
|
|
92
|
+
> misreading.** The sentence "somewhere for people to chat" *still finds nothing*, and no
|
|
93
|
+
> alias list can change that: the matcher ANDs every whitespace-separated term, so
|
|
94
|
+
> `somewhere` and `people` must appear in the haystack too. Making a sentence work means
|
|
95
|
+
> OR-then-rank, which §3 and §5 reject on their own merits. Aliases raise the odds that an
|
|
96
|
+
> owner's **one word** lands on the right row; they do not teach the filter to read a
|
|
97
|
+
> phrase. `tests/projects_hub_module_picker.mjs` pins both halves — the word that now
|
|
98
|
+
> works, and the sentence that still does not — so this boundary is proven rather than
|
|
99
|
+
> promised.
|
|
100
|
+
>
|
|
101
|
+
> **Why this instead of the embeddings layer this section deferred to.** Task 1003500 was
|
|
102
|
+
> that layer, and it was declined on measurement rather than on principle. The catalog is
|
|
103
|
+
> 22 modules whose only free text is a title and one sentence — there is no `keywords` or
|
|
104
|
+
> `category` field in the manifest schema to embed. Against that, the semantic path costs a
|
|
105
|
+
> paid provider account, a key route, a `pgvector` migration and an index, on a project
|
|
106
|
+
> whose retrieval budget ([ADR 0060](0060-gds-retrieval-layer.md)) rules out exactly that
|
|
107
|
+
> kind of addition "unless lexical-in-Postgres demonstrably can't do the job". It was never
|
|
108
|
+
> shown that it can't: the go/no-go that was supposed to decide this (GDS-V4 R24, with
|
|
109
|
+
> pgvector as R32) was **abandoned in July 2026 by [ADR 0103](0103-gdsv4-bongos-consolidation.md)**
|
|
110
|
+
> and never run, so the "deferred" above had quietly been pointing at dead tasks.
|
|
111
|
+
>
|
|
112
|
+
> A declared word list is also the better artifact at this size: it is deterministic, it costs
|
|
113
|
+
> nothing to run, and when it gets something wrong the person who read the complaint can fix
|
|
114
|
+
> it in one line. `tests/provisioning_starter_bundles.mjs` requires at least three aliases per
|
|
115
|
+
> opt-in module and rejects one the row's own copy already contains, so the list cannot rot
|
|
116
|
+
> into decoration and a new opt-in module cannot ship unfindable.
|
|
117
|
+
>
|
|
118
|
+
> Aliases are **never rendered** — the same standing §3 already gives the key. The swap §4
|
|
119
|
+
> anticipated ("a future by-meaning search replaces `moduleMatches` and nothing else") is
|
|
120
|
+
> still available if the catalog ever outgrows this.
|
|
121
|
+
|
|
81
122
|
The whole matcher is a named function on the page precisely so the vm harness in
|
|
82
123
|
`tests/projects_hub_module_picker.mjs` can run it directly — the same reason
|
|
83
124
|
`bundleForAnswers` is a function and not an inline expression.
|
package/docs/adr/README.md
CHANGED
|
@@ -332,7 +332,7 @@ This keeps the decision history honest and traceable.
|
|
|
332
332
|
| 0242 | [**A cross-role cascade is a declaration table on the event that already exists** ([task 1003549](https://cloudbongos.com/builders#/task/1003549) · goal 1000074, criterion `artist-review-task-on-ship` — the split R04 invited in its own brief and [ADR 0241](0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md)'s dependency). R04 needs "every ship with copy or visuals GENERATES one", and its brief said the infrastructure did not exist, naming the spark queue as the prototype. Reading both surfaces shows the prototype is the wrong half: the spark queue ([task 1002921](https://cloudbongos.com/builders#/task/1002921)) and the copy-desk flag queue ([task 1003113](https://cloudbongos.com/builders#/task/1003113)) are both **PULL** — filtered listings of rows another role already created — and nothing anywhere generated work on a state transition, which is the **PUSH** R04 asks for. (`cascade-dispatch.js` is model-cost routing; named only because its name has misled more than one reader.) **Decision: no new bus.** The kernel seam registry's own header uses this exact scenario as its worked example, `task.shipped` is already emitted post-commit by both ship paths and already has two consumers (discord's broadcast, sessions' deferred reward) — so a cascade is ONE MORE LISTENER on it, registered in `routes/lifecycle.js` beside the port because that mount is the one boot hook tests also run. A cascade is a frozen entry `{ key, discipline, when(task), title(task), body(task) }` — **a declaration table, not a rules engine**: no condition language, no stored rules, no per-project config, no priority ordering; adding one appends an entry and the runner does not change (the [ADR 0237](0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md)/[0239](0239-a-recommendation-is-a-table-that-carries-its-own-reason.md) shape, for their reason — the generalisations worth building are the ones two or three real entries have shown they share). **`CASCADES` ships EMPTY and R04 writes the first rule**, asserted so the emptiness is a decision on the record; the mechanism is proved against the tests' own declarations rather than against its first consumer's shape. Four load-bearing properties, all pinned: (1) **it can never fail the ship it reacts to** — post-commit event, isolated listeners, and a runner that catches everything and returns a report, because ADR 0241 settled that an artist may hold a DEPLOY and never a builder's SHIP and a generator that could fail a ship would smuggle that gate back in; verified against a real failure (no database reachable → one log line, resolves); (2) **idempotent on machinery that already exists** — `source: 'cascade'` + `source_ref: cascade-<key>-<taskId>` through `createTask`'s `(source, source_ref)` read, the same one a copy proposal uses ([ADR 0233](0233-a-copy-proposal-is-a-task-carrying-a-patch.md)), so a reconciler re-sweep or a double emit is a no-op; (3) **it says where it came from** — inherits `goal_id`/`version_id`, carries the origin id in the ref and the prose, but NOT `parent_task_id`, which models a SUBTASK, and cascaded work is not part of the ship that triggered it; (4) **files at `backlog`, never `ready`** — generated work is a request, and auto-filing claimable work would let a rule nobody re-read mint claims. A rule key is PERMANENT (half the source_ref; renaming re-files everything it ever generated), pinned by a duplicate-key check. The mechanism is discipline-agnostic — an ideator or engineer cascade is the same entry with a different `discipline`, which is the generalisation that made this its own task. Accepted limit: cascades are declared in code, not configured per project. Rejected: extending the spark queue (a read-side filter that generates nothing), a new event or poller, a rules engine or per-project config, filing at `ready`, `parent_task_id` as the link, and running the cascade inside the ship transaction (which would make a generation failure a ship failure — the exact coupling ADR 0241 rules out).](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) | goal 1000074 / the artist's loop |
|
|
333
333
|
| 0243 | [**A bundle adjustment belongs to the bundle, not to the advice about it** ([task 1002341](https://cloudbongos.com/builders#/task/1002341) · goal 1000046, criterion `proj-module-select`). R17 ([ADR 0239](0239-a-recommendation-is-a-table-that-carries-its-own-reason.md)) built a rule engine and R20 ([ADR 0240](0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md)) built a picker, and nothing connected them: the picker preselected the type's RAW preset while `GET /provisioning/recommendations` separately applied `TEAM_SHAPE_RULES` to that same preset and returned a better answer no surface rendered. Joining them the obvious way — let the picker apply the rules — breaks the property R20 was built on ([ADR 0240](0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md) §2: the preselect and the fallback are "one function, called twice"), because an owner who answers the interview, watches the panel preselect `discord`, and then SKIPS the picker would get a project without it. **Decision: `TEAM_SHAPE_RULES` moves from `recommendations.js` down to `starter-bundles.js`** (re-exported, so the public surface is unchanged), `effectiveModules` resolves through the new `bundleForAnswers(type, teamShape)`, and the picker reads the same table the fallback does. The distinction is not a preference: a bundle adjustment is not advice ABOUT the bundle — it IS the bundle once the owner has answered, and it is the only one of R17's three tables that is ever APPLIED (governance is a preview carrying `applied: false`, invites are a seam for R28), so it is the only one that has to live where the resolution happens. `effectiveModules` reads `detail.team_shape` straight off the row rather than through `normalizeProjectDetail` — importing it would point the bundle layer UP at the module that requires it ([ADR 0083](0083-modular-architecture-strangler-build-plan.md)) to buy an answer it already has, since an off-vocabulary shape fires no rule and adjusts nothing. `GET /provisioning/starter-bundles` gains `byTeamShape`: every bundle re-resolved for every ruled answer, with the `adjustments` that got it there — twenty small rows, the whole preselect table in ONE read, for the reason `optional` rides the same payload (the owner flips type and team-shape on two EARLIER panels, and a refetch could land after they moved on or paint a key the panel has no row for). `PATCH /provisioning/instances/:id/detail` returns `modules: effectiveModules(updated)` beside its `publish` verdict and for the same reason — changing `team_shape` MOVES the modules of a `source: 'bundle'` project, so the effect comes back with the cause. The panel renders one reason line per adjustment that ACTUALLY fired, and only while the owner has not answered for themselves (a reason handed back for a toggle they flipped reads as the panel arguing with them). An adjusted bundle returns in CATALOG order, never append order, or the same set of modules would read as a different list depending on whether the picker was answered or skipped; a payload without `byTeamShape` degrades to the R20 answer, which is never wrong, only untailored. No new storage — the adjustment is resolved, never written. Rejected: leaving the rules up in `recommendations.js` and having the picker call `/recommendations` (two lists kept in agreement by hand, plus a round trip per keystroke); importing `normalizeProjectDetail`; storing the adjusted bundle on a skip (it writes down an answer nobody gave); moving governance and invites down too (they are never applied, so there is nothing for them to stay in agreement with); and a reason line per matched condition rather than per actual change.](0243-a-bundle-adjustment-belongs-to-the-bundle-not-the-advice-about-it.md) | provisioning / project creation |
|
|
334
334
|
| 0245 | [**The way back into an optional step is that step’s own answer route** ([task 1002342](https://cloudbongos.com/builders#/task/1002342) · goal 1000046, criterion `proj-module-select`). R20 ([ADR 0240](0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md)) made the create wizard’s module picker skippable and left a project’s module set write-once — the route file said so out loud (“there is no mutation route yet”). R23 closes it WITHOUT a new endpoint: `modules` joins `description` and `detail` on `PATCH /provisioning/instances/:id/detail`, because the wizard’s two optional steps are the same kind of thing — asked optionally at creation, stored on the platform row, reaching no running instance — and that route already carried the ownership gate, the `api:self`/`api:archon:<id>` actor rule, the 404-no-existence-leak shape, and (since [ADR 0243](0243-a-bundle-adjustment-belongs-to-the-bundle-not-the-advice-about-it.md)) an `effectiveModules` answer it could report but not set. Deliberately NOT the settings channel: a setting is a policy the running instance obeys and would queue a restart to deliver a value the instance does not read. Only an ARRAY is a change — an omitted or null key leaves the column alone — so the three-valued contract’s `NULL` (“never asked”) stays unreachable by a later edit, and `modules` REPLACES where `detail` merges, because a merging write would make removal impossible. The surface is the manage view’s extras band (`/projects?view=manage&id=N`), where a toggle IS the save and the band repaints from the platform’s answer rather than from what was sent.](0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md) | provisioning / project creation |
|
|
335
|
-
| 0244 | [**A catalog search is a view over the rows, never an edit to the selection** ([task 1002340](https://cloudbongos.com/builders#/task/1002340) · goal 1000046, criterion `proj-module-select`). The creation picker ([ADR 0240](0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md)) paints one toggle per optional module, and the catalog is meant to grow — so R21 adds a keyword search over it. A search over a list of links is trivial; a search over a list of TOGGLES is not, because the list is also an answer. Two failure modes follow from letting a row be hidden: the filter **edits** the answer (narrow the rendered array and the read-back array together, and typing `discord` then pressing Next files one module instead of three — silently, with every screen looking right), or the filter **lies** about it (a switched-on module off screen reads as switched off, which is [ADR 0237](0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md)’s "a preselect they have to audit" in its harsher form — one they cannot see at all). **Decision: the search is a VIEW and `state.modules` is the answer; the view may not write to it.** `moduleQuery` is a plain variable, never part of the draft, so `saveDraft` is not called from the input handler and a search can never turn an untouched picker into an answered one (ADR 0240’s `null` survives); `visibleModules()` derives the shown rows per render while `pickedModules()`, `modulesForCreate()` and `extrasRow()` keep reading the whole catalog; and the panel STATES what the filter is hiding — one line under the field counting what is **on**, not what is visible. Matching is `moduleMatches(mod, terms)`, a pure function: lower-cased **label + summary + key** as one haystack, every whitespace-separated term an AND’d substring. AND because adding a word means the list is still too long; substring because a filter that shows nothing until you finish typing reads as broken; the key because that is what a returning owner names a module by. No ranking (reordering toggles under a typing hand is worse than not), and deliberately **not semantic** — by-meaning search is deferred with the rest of the creation AI layer (idea 1000552). Rejected: filtering the read-back array (failure mode 1), CSS-hiding non-matches (leaves invisible `aria-pressed` toggles in the a11y tree), pinning switched-on rows above the query (a filter that lies the other way), persisting the query in the draft, and debouncing a pass over already-loaded rows.](0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md) |
|
|
335
|
+
| 0244 | [**A catalog search is a view over the rows, never an edit to the selection** ([task 1002340](https://cloudbongos.com/builders#/task/1002340) · goal 1000046, criterion `proj-module-select`). The creation picker ([ADR 0240](0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md)) paints one toggle per optional module, and the catalog is meant to grow — so R21 adds a keyword search over it. A search over a list of links is trivial; a search over a list of TOGGLES is not, because the list is also an answer. Two failure modes follow from letting a row be hidden: the filter **edits** the answer (narrow the rendered array and the read-back array together, and typing `discord` then pressing Next files one module instead of three — silently, with every screen looking right), or the filter **lies** about it (a switched-on module off screen reads as switched off, which is [ADR 0237](0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md)’s "a preselect they have to audit" in its harsher form — one they cannot see at all). **Decision: the search is a VIEW and `state.modules` is the answer; the view may not write to it.** `moduleQuery` is a plain variable, never part of the draft, so `saveDraft` is not called from the input handler and a search can never turn an untouched picker into an answered one (ADR 0240’s `null` survives); `visibleModules()` derives the shown rows per render while `pickedModules()`, `modulesForCreate()` and `extrasRow()` keep reading the whole catalog; and the panel STATES what the filter is hiding — one line under the field counting what is **on**, not what is visible. Matching is `moduleMatches(mod, terms)`, a pure function: lower-cased **label + summary + key** as one haystack, every whitespace-separated term an AND’d substring. AND because adding a word means the list is still too long; substring because a filter that shows nothing until you finish typing reads as broken; the key because that is what a returning owner names a module by. No ranking (reordering toggles under a typing hand is worse than not), and deliberately **not semantic** — by-meaning search is deferred with the rest of the creation AI layer (idea 1000552). Rejected: filtering the read-back array (failure mode 1), CSS-hiding non-matches (leaves invisible `aria-pressed` toggles in the a11y tree), pinning switched-on rows above the query (a filter that lies the other way), persisting the query in the draft, and debouncing a pass over already-loaded rows. **AMENDED 2026-09-22 by [task 1003500](https://cloudbongos.com/builders#/task/1003500): "no synonyms" is reversed — rows carry declared search `aliases`, so typing `chat` finds Discord.** The SENTENCE "somewhere for people to chat" still finds nothing and no alias list can change that (the matcher ANDs every term, so `somewhere` and `people` must appear too); aliases raise the odds an owner's one word lands, they do not teach the filter to read a phrase, and both halves are pinned by tests. "Not semantic" stands: the embeddings layer this row deferred to was DECLINED, not built. Its go/no-go (GDS-V4 R24, pgvector R32) had been abandoned by ADR 0103 in July and never run, so the deferral was pointing at dead tasks; and the catalog is 22 modules whose only free text is a title and one sentence, against a provider account, key route, migration and index that ADR 0060's budget rules out absent proof lexical can't do the job. A declared word list is deterministic, free to run and fixable by whoever reads the complaint; `tests/provisioning_starter_bundles.mjs` requires ≥3 aliases per opt-in module and rejects one its own copy already contains.](0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md) |
|
|
336
336
|
| 0247 | [**The join door is ONE composed answer, and dark matter has no live verdict to compose with yet** ([task 1002331](https://cloudbongos.com/builders#/task/1002331) · goal 1000046, criterion `proj-join-policy`). [ADR 0182](0182-project-visibility-two-axes.md) D6 wrote the rule — visibility is the ceiling, join policy may only narrow it — and nothing implemented it: the public `POST /access-requests` read `joinability` alone, so an owner could set their project private or stealth on the map and the application endpoint behaved exactly as before. R12 adds the third knob (`join_grant` = view | apply | full, one line of settings vocabulary, default `full` so nothing existing changes) and composes all three in one pure kernel file, `src/bongos/project-door.js`, published through the doorway so no module can re-open a door the others closed. Three things worth carrying: the door vocabulary is a list ordered widest→strictest, so “the stricter wins” is the data structure rather than a branch; the refusal never names WHICH knob closed the door, because a stealth project that could be identified by its 403 gives back exactly what R06’s server-side redaction protects; and `locked` (dark matter) is specified, tested and deliberately UNWIRED — the instance cannot derive publishability (no description in its pack, and a pushed verdict goes stale the moment the owner writes one), while the hub’s cached copy says every live project is dark matter today, so enforcing off it would refuse every join on the platform.](0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md) | onboarding / provisioning / project creation |
|
|
337
337
|
| 0248 | [**A hub project’s owner is the provisioning row, carried on a port and bound to both halves of the catalog projection** ([task 1003580](https://cloudbongos.com/builders#/task/1003580) · goal 1000046, criterion `proj-invite-builders`). [ADR 0246](0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md) made a wizard-made project resolvable by `POST /projects/invite` and named the remainder: the route is gated `project.curate` (floor metic) with no own-scope arm, so the project’s own owner — a `xenos` on the hub — got 403 on their own project, blocking BV1.R27–R29 and the C6 proof. The arm admits a `project.curate` holder **OR** the owner, where owner means `provisioning_instances.owner_builder_id` — never the hub’s `owner_github_id` columns, which [ADR 0238](0238-the-join-door-is-a-provisioning-settings-policy-key.md) fixes as attribution. Three things worth carrying: the fact travels on a NEW port `provisioning.projectOwnedBy` answering a **boolean**, so the comparison stays on the plane that holds the row and no consumer handles an ownership id it could mis-compare; the port is asked about **both halves of the ADR 0246 projection** (slug AND origin), because a hand-seeded catalog row’s name is otherwise a squattable claim on that project’s membership directory; and ownership is asked BEFORE the target is resolved, so the refusal stays the untouched `requirePermission` 403 and cannot become an existence oracle over stealth projects ([ADR 0182](0182-project-visibility-two-axes.md) D2a). The atom stays spelled in the route’s own middleware list ([ADR 0210](0210-scouting-graduates-on-an-instance-asserted-owner-claim.md)’s rule), so the route still classifies `metic+archon`.](0248-a-hub-project-s-owner-is-the-provisioning-row-carried-on-a-port.md) | onboarding / provisioning / project creation |
|
|
338
338
|
|
|
@@ -2402,5 +2402,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2402
2402
|
landed since 1.19.962 with no explicit bump. run 35770645163. (task 1002620)
|
|
2403
2403
|
1.19.964 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2404
2404
|
landed since 1.19.963 with no explicit bump. run 35787848112. (task 1002620)
|
|
2405
|
+
1.19.965 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2406
|
+
landed since 1.19.964 with no explicit bump. run 35790478196. (task 1002620)
|
|
2407
|
+
1.19.966 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2408
|
+
landed since 1.19.965 with no explicit bump. run 35793400408. (task 1002620)
|
|
2405
2409
|
---------------------------------------------------------------------------
|
|
2406
2410
|
```
|
|
@@ -100,16 +100,37 @@ const RETIRED_MODULES = Object.freeze(['dev-box']);
|
|
|
100
100
|
// tests/provisioning_starter_bundles.mjs be the link back to the manifests — it
|
|
101
101
|
// fails the build when the declared set drifts from the on-disk `default: false`
|
|
102
102
|
// modules, so a new opt-in module cannot ship without owner-facing copy.
|
|
103
|
+
// `aliases` are search words only — never rendered (the same standing the `key`
|
|
104
|
+
// already has, ADR 0244 §3). They exist because the picker's search is a literal
|
|
105
|
+
// substring filter over words we chose, so a module is findable only under the
|
|
106
|
+
// vocabulary its own copy happens to use: typing "chat" found nothing, which is
|
|
107
|
+
// the gap ADR 0244 named and deferred to an embeddings layer. Task 1003500 closed
|
|
108
|
+
// it here instead — for a catalog this size a declared word list is more accurate
|
|
109
|
+
// than a model, costs nothing to run, and is fixable by whoever reads the
|
|
110
|
+
// complaint (ADR 0244 §3 amendment).
|
|
111
|
+
//
|
|
112
|
+
// This fixes the WORD, not the SENTENCE. ADR 0244 §3 illustrated the gap with
|
|
113
|
+
// "somewhere for people to chat", and that query still finds nothing: the matcher
|
|
114
|
+
// ANDs every term, so `somewhere` and `people` must appear too. Making a sentence
|
|
115
|
+
// work means OR-then-rank, which §3 and §5 reject outright. Aliases raise the
|
|
116
|
+
// chance an owner's ONE word lands; they do not make the filter understand a
|
|
117
|
+
// phrase, and nothing here should imply they do.
|
|
118
|
+
//
|
|
119
|
+
// Write the LONGEST form an owner might type: the filter is substring-based, so
|
|
120
|
+
// 'chatroom' in the haystack already answers "chat". A word the label or summary
|
|
121
|
+
// already contains does not belong here — it is findable without it.
|
|
103
122
|
const OPTIONAL_MODULES = Object.freeze([
|
|
104
123
|
Object.freeze({
|
|
105
124
|
key: 'agents',
|
|
106
125
|
label: 'Extra agents',
|
|
107
126
|
summary: 'More AI agents alongside the ones every project gets, for reading, research and review.',
|
|
127
|
+
aliases: Object.freeze(['bots', 'assistants', 'automation', 'llm', 'helpers']),
|
|
108
128
|
}),
|
|
109
129
|
Object.freeze({
|
|
110
130
|
key: 'discord',
|
|
111
131
|
label: 'Discord',
|
|
112
132
|
summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.',
|
|
133
|
+
aliases: Object.freeze(['chatroom', 'talk', 'messaging', 'community', 'announcements', 'notifications']),
|
|
113
134
|
}),
|
|
114
135
|
]);
|
|
115
136
|
|
|
@@ -241,9 +262,12 @@ function allStarterBundles() {
|
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
// The opt-in catalog, as the picker reads it — fresh copies, so a caller may
|
|
244
|
-
// sort or splice without corrupting the frozen declaration.
|
|
265
|
+
// sort or splice without corrupting the frozen declaration. `aliases` is copied
|
|
266
|
+
// too: a spread is shallow, so the old one-level copy handed every caller the
|
|
267
|
+
// same FROZEN array, and the first one to take the liberty this comment offers
|
|
268
|
+
// would have got a TypeError under strict mode rather than a private copy.
|
|
245
269
|
function optionalModules() {
|
|
246
|
-
return OPTIONAL_MODULES.map((m) => ({ ...m }));
|
|
270
|
+
return OPTIONAL_MODULES.map((m) => (m.aliases ? { ...m, aliases: [...m.aliases] } : { ...m }));
|
|
247
271
|
}
|
|
248
272
|
|
|
249
273
|
// Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
|
|
@@ -3528,12 +3528,25 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3528
3528
|
return String(q == null ? '' : q).toLowerCase().split(/\s+/).filter(function (t) { return t.length > 0; });
|
|
3529
3529
|
}
|
|
3530
3530
|
/* Does one catalog row answer this search? Every term must appear somewhere in
|
|
3531
|
-
the row's own words — its name, its one-line summary,
|
|
3532
|
-
substring, case-insensitively. AND across
|
|
3533
|
-
substring, so a half-typed word still
|
|
3531
|
+
the row's own words — its name, its one-line summary, its key, or its
|
|
3532
|
+
declared search aliases — as a substring, case-insensitively. AND across
|
|
3533
|
+
terms, so adding a word narrows; substring, so a half-typed word still
|
|
3534
|
+
finds its module.
|
|
3535
|
+
|
|
3536
|
+
The aliases are why typing "chat" now finds Discord: the row is findable
|
|
3537
|
+
under words an owner would use, not only the ones its own copy happens to
|
|
3538
|
+
use. They are declared in starter-bundles.js and never rendered — the same
|
|
3539
|
+
standing the key already has (ADR 0244 §3, amended by task 1003500).
|
|
3540
|
+
|
|
3541
|
+
Still literal, and still AND across terms, so a SENTENCE still finds
|
|
3542
|
+
nothing — "somewhere for people to chat" fails on `somewhere` and `people`
|
|
3543
|
+
however many aliases the row carries. That is the honest limit of this
|
|
3544
|
+
filter, not a bug in the list. */
|
|
3534
3545
|
function moduleMatches(mod, terms) {
|
|
3535
3546
|
if (!terms.length) return true;
|
|
3536
|
-
var
|
|
3547
|
+
var aliases = (mod && mod.aliases) || [];
|
|
3548
|
+
var hay = (((mod && mod.label) || '') + ' ' + ((mod && mod.summary) || '') + ' ' + ((mod && mod.key) || '')
|
|
3549
|
+
+ ' ' + (aliases.join ? aliases.join(' ') : '')).toLowerCase();
|
|
3537
3550
|
for (var i = 0; i < terms.length; i++) if (hay.indexOf(terms[i]) === -1) return false;
|
|
3538
3551
|
return true;
|
|
3539
3552
|
}
|
|
@@ -128,8 +128,8 @@ const STARTER_BUNDLES_FIXTURE = {
|
|
|
128
128
|
{ type: 'not-sure', bundle: [] },
|
|
129
129
|
],
|
|
130
130
|
optional: [
|
|
131
|
-
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets, for reading, research and review.' },
|
|
132
|
-
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.' },
|
|
131
|
+
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets, for reading, research and review.', aliases: ['bots', 'assistants', 'automation', 'llm', 'helpers'] },
|
|
132
|
+
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.', aliases: ['chatroom', 'talk', 'messaging', 'community', 'announcements', 'notifications'] },
|
|
133
133
|
],
|
|
134
134
|
};
|
|
135
135
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.966",
|
|
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.966",
|
|
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.966",
|
|
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/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.966'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -67,10 +67,14 @@ const PAYLOAD = {
|
|
|
67
67
|
{ type: 'non-profit', bundle: ['discord'], summary: 'Somewhere the community around the cause can gather and be heard.' },
|
|
68
68
|
{ type: 'not-sure', bundle: [], summary: 'Just the core, and nothing else to decide today. Add modules whenever you know what you need.' },
|
|
69
69
|
],
|
|
70
|
+
// `aliases` — search words a row is findable under that its own copy never uses
|
|
71
|
+
// (task 1003500, amending ADR 0244 §3). Never rendered, like the key. dev-box
|
|
72
|
+
// carries none on purpose, so the "a row without aliases still matches on its own
|
|
73
|
+
// words" path is exercised by the same fixture rather than by a special case.
|
|
70
74
|
optional: [
|
|
71
|
-
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.' },
|
|
75
|
+
{ key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets.', aliases: ['bots', 'assistants', 'automation'] },
|
|
72
76
|
{ key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out first.' },
|
|
73
|
-
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news.' },
|
|
77
|
+
{ key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news.', aliases: ['chatroom', 'talk', 'community'] },
|
|
74
78
|
],
|
|
75
79
|
};
|
|
76
80
|
|
|
@@ -616,8 +620,8 @@ test('the picker and the server resolve the same set for every answer an owner c
|
|
|
616
620
|
const DEV_BOX = PAYLOAD.optional[1];
|
|
617
621
|
|
|
618
622
|
test('the matcher reads a row\'s own words — its name, its summary and its key', () => {
|
|
619
|
-
//
|
|
620
|
-
//
|
|
623
|
+
// Still lexical, never semantic: the only words it knows are the ones the owner
|
|
624
|
+
// can see, plus the two it cannot — the key, and the declared aliases below.
|
|
621
625
|
const h = boot({ state: { projType: 'game' } });
|
|
622
626
|
|
|
623
627
|
assert.equal(h.matches(DEV_BOX, 'boxes'), true, 'by a word in the name');
|
|
@@ -626,6 +630,77 @@ test('the matcher reads a row\'s own words — its name, its summary and its key
|
|
|
626
630
|
assert.equal(h.matches(DEV_BOX, 'discord'), false, 'and never by a word that is in none of them');
|
|
627
631
|
});
|
|
628
632
|
|
|
633
|
+
// ── task 1003500: the aliases, and the gap they close ────────────────────────
|
|
634
|
+
// ADR 0244 §3 said "no synonyms" and deferred search-by-meaning to an embeddings
|
|
635
|
+
// layer (idea 1000552). That layer was declined for this catalog: 22 modules whose
|
|
636
|
+
// only free text is a title and one sentence do not need a model to be findable,
|
|
637
|
+
// and a declared word list is fixable by whoever reads the complaint. These pin the
|
|
638
|
+
// outcome ADR 0244 actually named as missing.
|
|
639
|
+
|
|
640
|
+
test('a row is findable under a word an owner would use but its own copy never does', () => {
|
|
641
|
+
const h = boot({ state: { projType: 'game' } });
|
|
642
|
+
const discord = PAYLOAD.optional[2];
|
|
643
|
+
|
|
644
|
+
// The WORD at the heart of the example ADR 0244 §3 gave. Before this, none of
|
|
645
|
+
// these found anything: "chat", "talk" and "community" appear nowhere in
|
|
646
|
+
// Discord's label, summary or key.
|
|
647
|
+
assert.equal(h.matches(discord, 'chat'), true, 'typing "chat" finds Discord');
|
|
648
|
+
assert.equal(h.matches(discord, 'talk'), true, 'and so does the other word for it');
|
|
649
|
+
assert.equal(h.matches(discord, 'community'), true);
|
|
650
|
+
assert.equal(h.matches(PAYLOAD.optional[0], 'bots'), true, 'agents answers to "bots"');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('a SENTENCE still finds nothing — aliases fix the word, not the phrase', () => {
|
|
654
|
+
// The boundary, pinned rather than promised. ADR 0244 §3 illustrated the gap with
|
|
655
|
+
// "somewhere for people to chat", and it is tempting to read the aliases above as
|
|
656
|
+
// having closed exactly that. They have not: the matcher ANDs every term, so
|
|
657
|
+
// `somewhere` and `people` must be in the haystack too, and no alias list can make
|
|
658
|
+
// a phrase query work. The fix for that is OR-then-rank, which §3 and §5 reject on
|
|
659
|
+
// their own merits — so this row failing is the DESIGN, and a future change that
|
|
660
|
+
// makes it pass should have to come here and say why.
|
|
661
|
+
const h = boot({ state: { projType: 'game' } });
|
|
662
|
+
const discord = PAYLOAD.optional[2];
|
|
663
|
+
|
|
664
|
+
assert.equal(h.matches(discord, 'somewhere for people to chat'), false,
|
|
665
|
+
'the sentence ADR 0244 named is still outside a literal AND filter');
|
|
666
|
+
assert.equal(h.matches(discord, 'people to chat'), false, 'and so is a shorter phrase around the same word');
|
|
667
|
+
assert.equal(h.matches(discord, 'chat'), true, 'while the word inside it lands — that is the whole of what aliases buy');
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('an alias is a search word only — it is never shown to anyone', async () => {
|
|
671
|
+
// The same standing the key has. A row that advertised "chatroom, talk, community"
|
|
672
|
+
// under its summary would be noise on a screen that is trying to be a short list.
|
|
673
|
+
const h = boot({ state: { projType: 'game' } });
|
|
674
|
+
await h.open();
|
|
675
|
+
|
|
676
|
+
for (const row of h.rows()) {
|
|
677
|
+
for (const word of ['chatroom', 'bots', 'automation']) {
|
|
678
|
+
assert.ok(!String(row.label).includes(word), `label leaks "${word}"`);
|
|
679
|
+
assert.ok(!String(row.summary).includes(word), `summary leaks "${word}"`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('aliases narrow like any other word, and a row without them still matches on its own', () => {
|
|
685
|
+
const h = boot({ state: { projType: 'game' } });
|
|
686
|
+
const discord = PAYLOAD.optional[2];
|
|
687
|
+
|
|
688
|
+
assert.equal(h.matches(discord, 'chat news'), true, 'an alias AND a summary word still narrows to the row that has both');
|
|
689
|
+
assert.equal(h.matches(discord, 'chat workspace'), false, 'an alias does not rescue a term the row cannot answer');
|
|
690
|
+
assert.equal(h.matches(DEV_BOX, 'chat'), false, 'and a row declaring no aliases is not matched by another row\'s');
|
|
691
|
+
assert.equal(h.matches(DEV_BOX, 'workspace'), true, 'while its own words keep working untouched');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('typing an alias filters the band to that row, and the count says so', async () => {
|
|
695
|
+
const h = boot({ state: { projType: 'game' } });
|
|
696
|
+
await h.open();
|
|
697
|
+
|
|
698
|
+
h.type('chat');
|
|
699
|
+
|
|
700
|
+
assert.deepEqual(h.rows().map((r) => r.key), ['discord'], 'the band narrows to the row the alias names');
|
|
701
|
+
assert.match(h.filterNote(), /Showing 1 of 3/, 'and the line under the field counts honestly');
|
|
702
|
+
});
|
|
703
|
+
|
|
629
704
|
test('a term matches as a SUBSTRING, so a half-typed word still finds its module', () => {
|
|
630
705
|
const h = boot({ state: { projType: 'game' } });
|
|
631
706
|
|
|
@@ -379,6 +379,24 @@ test('every opt-in module carries owner-facing copy, and no module jargon rides
|
|
|
379
379
|
}
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
+
test('every opt-in module declares search words its own copy does not already carry', () => {
|
|
383
|
+
// Task 1003500. The picker's filter is literal, so a module is findable only under
|
|
384
|
+
// the vocabulary WE chose for it — which is how "somewhere for people to chat" came
|
|
385
|
+
// to find nothing (ADR 0244 §3). Requiring aliases here is the same move task 1003962
|
|
386
|
+
// made for the copy above: a new opt-in module cannot ship without them.
|
|
387
|
+
for (const row of OPTIONAL_MODULES) {
|
|
388
|
+
assert.ok(Array.isArray(row.aliases) && row.aliases.length >= 3,
|
|
389
|
+
`"${row.key}" needs at least three search aliases — an owner who does not know its name must still find it`);
|
|
390
|
+
const own = `${row.label} ${row.summary} ${row.key}`.toLowerCase();
|
|
391
|
+
for (const alias of row.aliases) {
|
|
392
|
+
assert.equal(alias, alias.toLowerCase().trim(), `"${row.key}" alias "${alias}" must be lower-case and trimmed — the haystack is lower-cased, never the query alone`);
|
|
393
|
+
assert.doesNotMatch(alias, /\s/, `"${row.key}" alias "${alias}" must be one word — the query is split on whitespace, so a phrase can never match`);
|
|
394
|
+
assert.ok(!own.includes(alias),
|
|
395
|
+
`"${row.key}" alias "${alias}" is already in its label, summary or key — it is findable without it, and a duplicate is a line to maintain for nothing`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
382
400
|
test('every module a bundle preselects is a row the picker can actually render', () => {
|
|
383
401
|
// A preselect with no catalog row would light up nothing — the panel would
|
|
384
402
|
// silently ship a choice the owner cannot see or switch off.
|
|
@@ -483,7 +501,13 @@ test('GET /provisioning/starter-bundles carries the opt-in catalog the picker re
|
|
|
483
501
|
assert.equal(res.status, 200, res.raw);
|
|
484
502
|
assert.deepEqual(res.body.optional, OPTIONAL_MODULES.map((m) => ({ ...m })));
|
|
485
503
|
for (const row of res.body.optional) {
|
|
486
|
-
|
|
504
|
+
// `aliases` joined this shape in task 1003500 — search words the picker's
|
|
505
|
+
// matcher reads and never renders. Optional, so a row may carry none.
|
|
506
|
+
assert.deepEqual(Object.keys(row).sort().filter((k) => k !== 'aliases'), ['key', 'label', 'summary']);
|
|
507
|
+
if (row.aliases !== undefined) {
|
|
508
|
+
assert.ok(Array.isArray(row.aliases) && row.aliases.every((a) => typeof a === 'string'),
|
|
509
|
+
`"${row.key}" aliases must be a list of strings — the panel joins them into one haystack`);
|
|
510
|
+
}
|
|
487
511
|
}
|
|
488
512
|
});
|
|
489
513
|
|