@bongos/core 1.21.150 → 1.21.152
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 +52 -52
- package/docs/copy-inventory.md +16 -16
- package/docs/copy-registry.json +16 -16
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +29 -0
- package/docs/page-readings.json +27 -26
- package/modules/agents/module.json +1 -0
- package/modules/autonomy/module.json +1 -0
- package/modules/builder-settings/module.json +1 -0
- package/modules/copy-desk/module.json +1 -0
- package/modules/design-styles/module.json +1 -0
- package/modules/discord/module.json +1 -0
- package/modules/economy/module.json +1 -0
- package/modules/government/module.json +1 -0
- package/modules/grading/module.json +1 -0
- package/modules/hall-ui/module.json +1 -0
- package/modules/hall-ui/public/modules.js +16 -4
- package/modules/ideas/module.json +1 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/memory/module.json +1 -0
- package/modules/npm-release/module.json +1 -0
- package/modules/onboarding/module.json +1 -0
- package/modules/page-notes/module.json +1 -0
- package/modules/pixel-art/module.json +1 -0
- package/modules/platform-identity/module.json +1 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/public-landing/module.json +1 -0
- package/modules/render-deploy/module.json +1 -0
- package/modules/security/module.json +1 -0
- package/modules/sessions/module.json +1 -0
- package/modules/specialities/module.json +1 -0
- package/modules/status-ui/module.json +1 -0
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/credential-presence.js +10 -0
- package/scripts/gds/module-test-runner/README.md +3 -3
- package/scripts/gds/module-test-runner/module-tests.yml +18 -6
- package/scripts/gds/module-test-runner.js +92 -36
- package/scripts/gds/module.js +4 -2
- package/src/module-api.js +1 -1
- package/src/module-loader/catalog.js +15 -1
- package/src/module-loader/manifest-schema.js +40 -1
- package/tests/hall_catalog_world.mjs +4 -1
- package/tests/module_catalog.mjs +34 -0
- package/tests/module_manifest.mjs +36 -0
- package/tests/module_test_runner.mjs +70 -17
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"description": "The platform capability that lets any builder on any instance create and design good-looking, functional UIs in line with that instance's world (scoped with the owner 2026-08-28; task 1003324, ADR 0197). It ships METHOD and never a world: the neutral design contract of ADR 0081 with its tool adapters (Claude Design, Figma), their sync CLIs and the validator; the /design playbook for ui-discipline work; the look-before-you-ship kit (kit/) and the style library (styles/). The thirteen design-style skills that lived in skills/ are the opt-in design-styles module since task 1004470; /design reaches them when that module is on. The instance's world — its branding pack, config/design-tokens.*, config/design-sources.json and its DESIGN.md — stays in config/ and at the repo root as the host boundary this module reads and never owns (ADR 0062 §7). Cloud Bongos's own front door is this module's first customer, not its owner. default-on because every instance has a face. No routes, no migrations, no port: declaration-only like character-anim, so disabling it withdraws only the skills it contributes and touches no served surface.",
|
|
5
5
|
"version": "1.0.0",
|
|
6
6
|
"coreVersion": "^1.19.0",
|
|
7
|
+
"category": { "parent": "pages" },
|
|
7
8
|
"default": true,
|
|
8
9
|
"maintenance": { "status": "core-maintained" },
|
|
9
10
|
"contributes": { "skills": ["design", "design-sync", "figma-design-sync"], "disciplines": ["ui"] },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.152",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.152",
|
|
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.21.
|
|
3
|
+
"version": "1.21.152",
|
|
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
|
@@ -9914,5 +9914,17 @@
|
|
|
9914
9914
|
"id": "1004760",
|
|
9915
9915
|
"text": "When a project's GitHub App isn't installed, a refused ship now says so and links the one-click install, instead of a bare 'not proven'."
|
|
9916
9916
|
}
|
|
9917
|
+
],
|
|
9918
|
+
"1.21.151": [
|
|
9919
|
+
{
|
|
9920
|
+
"id": "1004739",
|
|
9921
|
+
"text": "The module test runner can now test against the private core: the server sends its own copy of the core into the private test repo, once per version."
|
|
9922
|
+
}
|
|
9923
|
+
],
|
|
9924
|
+
"1.21.152": [
|
|
9925
|
+
{
|
|
9926
|
+
"id": "1003784",
|
|
9927
|
+
"text": "Every module now says which of the eight categories it belongs to, and the module list in the terminal and the hall's Modules tab both show it and let you search by it."
|
|
9928
|
+
}
|
|
9917
9929
|
]
|
|
9918
9930
|
}
|
|
@@ -121,6 +121,16 @@ const CREDENTIALS = [
|
|
|
121
121
|
names: ['METRICS_TOKEN'],
|
|
122
122
|
purpose: 'bearer token for a remote Prometheus scrape of /metrics (src/bongos/metrics.js)',
|
|
123
123
|
},
|
|
124
|
+
{
|
|
125
|
+
id: 'module-test-runner', file: 'web.env', need: 'optional',
|
|
126
|
+
names: ['MODULE_TEST_REPO', 'MODULE_TEST_TOKEN'],
|
|
127
|
+
purpose: "runs store modules' own tests in a separate private GitHub repo (scripts/gds/module-test-runner.js, blocker 1000195); unset leaves Tests pending",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'module-test-ref', file: 'web.env', need: 'optional',
|
|
131
|
+
names: ['MODULE_TEST_REF'], notSecret: true,
|
|
132
|
+
purpose: "the test repo branch holding the module-tests workflow (default main)",
|
|
133
|
+
},
|
|
124
134
|
{
|
|
125
135
|
id: 'hub-signing-key', file: 'web.env', need: 'optional',
|
|
126
136
|
names: ['HUB_SIGNING_KEY'], prefixed: true,
|
|
@@ -22,9 +22,9 @@ stays `pending`.
|
|
|
22
22
|
MODULE_TEST_REPO=<owner>/<repo>
|
|
23
23
|
MODULE_TEST_TOKEN=<the token>
|
|
24
24
|
```
|
|
25
|
-
Optional: `
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
Optional: `MODULE_TEST_REF=<branch>` if the test repo's workflow lives on a
|
|
26
|
+
branch other than `main`. Nothing names the core repo: the runner gets the
|
|
27
|
+
core from the test repo itself (see the header of `module-test-runner.js`).
|
|
28
28
|
|
|
29
29
|
On the runner, a module's tests run as a separate user with no admin rights and
|
|
30
30
|
no network access (only the machine's own loopback), after the core is installed.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
# nothing here ever holds a DB credential or a control-plane secret.
|
|
7
7
|
#
|
|
8
8
|
# Two jobs on purpose: `fetch` can read the repo (where the tarball waits on a
|
|
9
|
-
# throwaway branch
|
|
9
|
+
# throwaway branch, and the core snapshot on core/<sha>) but runs no module code;
|
|
10
|
+
# `test` runs the module's code with a
|
|
10
11
|
# GITHUB_TOKEN that has NO permissions, so a module that scrapes its token still
|
|
11
12
|
# cannot read another run's tarball. Inside `test`, the module's code runs as an
|
|
12
13
|
# unprivileged user with its network cut (see "Cut the network for module code").
|
|
@@ -19,8 +20,7 @@ on:
|
|
|
19
20
|
version_id: { description: store_module_versions.id, required: true, type: string }
|
|
20
21
|
module_key: { description: the module's key, required: true, type: string }
|
|
21
22
|
nonce: { description: this run's nonce (32 hex), required: true, type: string }
|
|
22
|
-
|
|
23
|
-
core_ref: { description: the core commit to test against, required: true, type: string }
|
|
23
|
+
core_ref: { description: the core commit to test against (40 hex; branch core/<sha> holds it), required: true, type: string }
|
|
24
24
|
|
|
25
25
|
permissions: {}
|
|
26
26
|
|
|
@@ -31,14 +31,21 @@ jobs:
|
|
|
31
31
|
timeout-minutes: 5
|
|
32
32
|
steps:
|
|
33
33
|
- name: Check the inputs
|
|
34
|
-
env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}" }
|
|
34
|
+
env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}", CORE_REF: "${{ inputs.core_ref }}" }
|
|
35
35
|
run: |
|
|
36
36
|
[[ "$NONCE" =~ ^[0-9a-f]{32}$ ]] || { echo "bad nonce"; exit 1; }
|
|
37
37
|
[[ "$VERSION_ID" =~ ^[0-9]+$ ]] || { echo "bad version id"; exit 1; }
|
|
38
|
+
[[ "$CORE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "bad core ref"; exit 1; }
|
|
38
39
|
- uses: actions/checkout@v4
|
|
39
40
|
with: { ref: "module-test/${{ inputs.nonce }}", path: tarball, persist-credentials: false }
|
|
41
|
+
# The core repo is private, so the control plane pushes a snapshot of the
|
|
42
|
+
# core it runs to core/<sha> in THIS repo (once per commit; task 1004739).
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
with: { ref: "core/${{ inputs.core_ref }}", path: core-snapshot, persist-credentials: false }
|
|
40
45
|
- uses: actions/upload-artifact@v4
|
|
41
46
|
with: { name: "tarball-${{ inputs.nonce }}", path: tarball/module.tgz, retention-days: 1 }
|
|
47
|
+
- uses: actions/upload-artifact@v4
|
|
48
|
+
with: { name: "core-${{ inputs.nonce }}", path: core-snapshot/core.tgz, retention-days: 1, compression-level: 0 }
|
|
42
49
|
|
|
43
50
|
test:
|
|
44
51
|
needs: fetch
|
|
@@ -46,12 +53,17 @@ jobs:
|
|
|
46
53
|
permissions: {}
|
|
47
54
|
timeout-minutes: 30
|
|
48
55
|
steps:
|
|
49
|
-
- uses: actions/checkout@v4
|
|
50
|
-
with: { repository: "${{ inputs.core_repo }}", ref: "${{ inputs.core_ref }}", path: core, persist-credentials: false }
|
|
51
56
|
- uses: actions/setup-node@v4
|
|
52
57
|
with: { node-version: 22 }
|
|
53
58
|
- uses: actions/download-artifact@v4
|
|
54
59
|
with: { name: "tarball-${{ inputs.nonce }}", path: in }
|
|
60
|
+
- uses: actions/download-artifact@v4
|
|
61
|
+
with: { name: "core-${{ inputs.nonce }}", path: core-in }
|
|
62
|
+
- name: Unpack the core
|
|
63
|
+
run: |
|
|
64
|
+
mkdir core
|
|
65
|
+
tar -xzf core-in/core.tgz -C core
|
|
66
|
+
rm -rf core-in
|
|
55
67
|
- name: Install the core
|
|
56
68
|
working-directory: core
|
|
57
69
|
run: npm ci --no-audit --no-fund
|
|
@@ -13,18 +13,31 @@
|
|
|
13
13
|
// THE ROUND TRIP. Nothing on the runner ever holds a DB credential:
|
|
14
14
|
// 1. dispatch — the control plane pushes the version's tarball to a throwaway
|
|
15
15
|
// orphan branch `module-test/<nonce>` in the test repo and dispatches the
|
|
16
|
-
// workflow with { version_id, module_key, nonce,
|
|
17
|
-
//
|
|
16
|
+
// workflow with { version_id, module_key, nonce, core_ref }. The nonce goes
|
|
17
|
+
// into the pending Tests row's detail.runner.
|
|
18
18
|
// 2. the run — a `fetch` job (contents: read, no module code) copies the tarball
|
|
19
|
-
// into the run as
|
|
20
|
-
//
|
|
21
|
-
// MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
|
|
19
|
+
// and the core snapshot into the run as artifacts; a separate `test` job
|
|
20
|
+
// (permissions: {}) unpacks the core, runs module-assess-tests.js --tarball
|
|
21
|
+
// with MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
|
|
22
22
|
// module-tests-<version_id>-<nonce>.
|
|
23
23
|
// 3. collect — the control plane PULLS: it finds the run whose run-name carries
|
|
24
24
|
// the pending row's nonce, takes only that run's artifact of that exact name,
|
|
25
25
|
// checks the payload names the same version and nonce, inserts the signal row
|
|
26
26
|
// itself, recomposes the score, and deletes the branch.
|
|
27
27
|
//
|
|
28
|
+
// WHY THE CORE TRAVELS AS A SNAPSHOT (task 1004739). The core repo is private, so
|
|
29
|
+
// a runner whose token has no permissions cannot check it out by name. Before the
|
|
30
|
+
// first run against a core commit, dispatch pushes `git archive` of that commit
|
|
31
|
+
// (tracked files only, so never node_modules; minus the design mocks in
|
|
32
|
+
// .impeccable/, which nothing runs) to an orphan branch `core/<sha>` in the test
|
|
33
|
+
// repo, with the same token. Once per commit: a later dispatch finds the ref and
|
|
34
|
+
// reuses it. The `fetch` job reads it with its contents: read token like the
|
|
35
|
+
// tarball, so the `test` job still needs no permission and no repo name at all.
|
|
36
|
+
// GitHub refuses a blob over 100 MB and the archive goes up base64-encoded (a
|
|
37
|
+
// third bigger), so a snapshot over MAX_CORE_BYTES fails the dispatch loudly: a
|
|
38
|
+
// dispatch that cannot be sent is logged as an error, not a warning, because the
|
|
39
|
+
// version's Tests part then stays pending until someone fixes the cause.
|
|
40
|
+
//
|
|
28
41
|
// WHY A PER-RUN BRANCH, NOT A DOWNLOAD TICKET. A ticket would mean a new
|
|
29
42
|
// unauthenticated route on the control plane (and a change to the protected
|
|
30
43
|
// route-rank-check), i.e. a fresh inbound path to the server holding the secrets.
|
|
@@ -37,53 +50,50 @@
|
|
|
37
50
|
// tests pass, so the score is never trust-critical; what this protects is the
|
|
38
51
|
// secrets and other authors' code.
|
|
39
52
|
//
|
|
40
|
-
// Configured by
|
|
41
|
-
// `pending`, exactly as before this task):
|
|
53
|
+
// Configured by control-plane env vars (repo + token unset = off; Tests then
|
|
54
|
+
// stays `pending`, exactly as before this task):
|
|
42
55
|
// MODULE_TEST_REPO owner/name of the private test repo
|
|
43
56
|
// MODULE_TEST_TOKEN a fine-grained token scoped to ONLY that repo, with
|
|
44
57
|
// Actions: read & write and Contents: read & write
|
|
45
|
-
// MODULE_TEST_CORE_REPO owner/name of the public core the runner checks out
|
|
46
|
-
// (default: this checkout's `origin`)
|
|
47
58
|
// MODULE_TEST_REF the test repo branch holding the workflow (default main)
|
|
48
59
|
//
|
|
49
60
|
// node scripts/gds/module-test-runner.js dispatch <store_module_versions.id>
|
|
50
61
|
// node scripts/gds/module-test-runner.js collect
|
|
51
62
|
|
|
52
63
|
const fs = require('node:fs');
|
|
64
|
+
const path = require('node:path');
|
|
53
65
|
const crypto = require('node:crypto');
|
|
54
66
|
const zlib = require('node:zlib');
|
|
55
|
-
const {
|
|
67
|
+
const { execFile } = require('node:child_process');
|
|
68
|
+
const { promisify } = require('node:util');
|
|
69
|
+
|
|
70
|
+
const execFileAsync = promisify(execFile);
|
|
56
71
|
|
|
57
72
|
const WORKFLOW_FILE = 'module-tests.yml';
|
|
58
73
|
const BRANCH_PREFIX = 'module-test/';
|
|
74
|
+
const CORE_BRANCH_PREFIX = 'core/';
|
|
59
75
|
const API = 'https://api.github.com';
|
|
60
76
|
// A run that has not finished this long after dispatch is recorded not_scored.
|
|
61
77
|
const MAX_RUN_AGE_MS = 6 * 60 * 60_000;
|
|
62
78
|
const MAX_SIGNAL_BYTES = 256 * 1024;
|
|
79
|
+
// GitHub's blob limit is 100 MB and the snapshot is sent base64 (4/3 the size).
|
|
80
|
+
const MAX_CORE_BYTES = 72 * 1024 * 1024;
|
|
81
|
+
const CORE_ROOT = path.join(__dirname, '..', '..');
|
|
63
82
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
64
83
|
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
84
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
65
85
|
|
|
66
86
|
const runName = (versionId, nonce) => `module-tests ${versionId} ${nonce}`;
|
|
67
87
|
const artifactName = (versionId, nonce) => `module-tests-${versionId}-${nonce}`;
|
|
68
88
|
|
|
69
|
-
function originRepo() {
|
|
70
|
-
try {
|
|
71
|
-
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: __dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
72
|
-
const m = url.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
73
|
-
return m ? m[1] : null;
|
|
74
|
-
} catch { return null; }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
89
|
// The runner config, or null when it is not set up (the default: Tests stays pending).
|
|
78
|
-
function runnerConfig(env = process.env
|
|
90
|
+
function runnerConfig(env = process.env) {
|
|
79
91
|
const repo = (env.MODULE_TEST_REPO || '').trim();
|
|
80
92
|
const token = (env.MODULE_TEST_TOKEN || '').trim();
|
|
81
93
|
if (!repo || !token) return null;
|
|
82
94
|
if (!REPO_RE.test(repo)) throw new Error(`MODULE_TEST_REPO must be owner/name, got "${repo}"`);
|
|
83
|
-
const coreRepo = (env.MODULE_TEST_CORE_REPO || '').trim() || origin();
|
|
84
|
-
if (!coreRepo || !REPO_RE.test(coreRepo)) throw new Error('MODULE_TEST_CORE_REPO is unset and the core repo could not be read from git origin');
|
|
85
95
|
const ref = (env.MODULE_TEST_REF || '').trim() || 'main';
|
|
86
|
-
return { repo, token,
|
|
96
|
+
return { repo, token, ref };
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
// One GitHub REST call. Resolves the parsed body (or null for 204); throws with
|
|
@@ -104,7 +114,54 @@ async function gh(cfg, method, urlPath, body, { fetchImpl = fetch } = {}) {
|
|
|
104
114
|
// The commit the control plane is running, so the runner tests against the same core.
|
|
105
115
|
function currentCoreRef() {
|
|
106
116
|
const { buildInfo } = require('../../src/build-info');
|
|
107
|
-
return buildInfo().commit
|
|
117
|
+
return buildInfo().commit;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The core at one commit, as the tar.gz the runner unpacks. Async, so the web
|
|
121
|
+
// process keeps serving while git compresses. It needs the commit in a git
|
|
122
|
+
// checkout here: a vendored core (node_modules/@bongos/core) has none, and gets
|
|
123
|
+
// an error that says so rather than git's own.
|
|
124
|
+
async function archiveCore(sha) {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await execFileAsync('git', ['archive', '--format=tar.gz', sha, '--', '.', ':(exclude).impeccable', ':(exclude)node_modules'],
|
|
127
|
+
{ cwd: CORE_ROOT, encoding: 'buffer', maxBuffer: 4 * MAX_CORE_BYTES });
|
|
128
|
+
return stdout;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const why = String((e.stderr && e.stderr.toString()) || e.message).trim().split('\n')[0];
|
|
131
|
+
throw new Error(`could not snapshot core commit ${sha.slice(0, 8)}: the core must run from a git checkout that holds it (a vendored install has none) — git said: ${why}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Push a one-file orphan commit to a new branch of the test repo.
|
|
136
|
+
async function pushOrphanBranch(cfg, branch, file, bytes, message, { fetchImpl } = {}) {
|
|
137
|
+
const r = `/repos/${cfg.repo}`;
|
|
138
|
+
const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: bytes.toString('base64'), encoding: 'base64' }, { fetchImpl });
|
|
139
|
+
const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: file, mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
|
|
140
|
+
const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message, tree: tree.sha, parents: [] }, { fetchImpl });
|
|
141
|
+
await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Make sure branch core/<sha> holds the core snapshot, pushing it only the first
|
|
145
|
+
// time. Resolves { branch, pushed }. Two dispatches racing on a new commit both
|
|
146
|
+
// push; the loser's ref create is refused (422) and it uses the winner's.
|
|
147
|
+
async function ensureCoreSnapshot(cfg, sha, { fetchImpl, archive = archiveCore } = {}) {
|
|
148
|
+
if (!SHA_RE.test(sha || '')) throw new Error(`the running core commit is unknown ("${sha || ''}"), so there is no core to test against`);
|
|
149
|
+
const branch = `${CORE_BRANCH_PREFIX}${sha}`;
|
|
150
|
+
try {
|
|
151
|
+
await gh(cfg, 'GET', `/repos/${cfg.repo}/git/ref/heads/${branch}`, null, { fetchImpl });
|
|
152
|
+
return { branch, pushed: false };
|
|
153
|
+
} catch (e) { if (e.status !== 404) throw e; }
|
|
154
|
+
const tgz = await archive(sha);
|
|
155
|
+
if (tgz.length > MAX_CORE_BYTES) {
|
|
156
|
+
throw new Error(`the core snapshot for ${sha.slice(0, 8)} is ${(tgz.length / 1048576).toFixed(1)} MB, over the ${MAX_CORE_BYTES / 1048576} MB the test repo can take; trim what archiveCore sends in scripts/gds/module-test-runner.js`);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await pushOrphanBranch(cfg, branch, 'core.tgz', tgz, `core snapshot ${sha}`, { fetchImpl });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
if (e.status === 422) return { branch, pushed: false };
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
return { branch, pushed: true };
|
|
108
165
|
}
|
|
109
166
|
|
|
110
167
|
function readVersionTarball(ver) {
|
|
@@ -112,27 +169,26 @@ function readVersionTarball(ver) {
|
|
|
112
169
|
return fs.promises.readFile(versionArtifactFile(ver));
|
|
113
170
|
}
|
|
114
171
|
|
|
115
|
-
//
|
|
116
|
-
// Resolves the runner record kept in the
|
|
117
|
-
|
|
172
|
+
// Make sure the core snapshot is there, push the tarball to a throwaway orphan
|
|
173
|
+
// branch, and dispatch the workflow. Resolves the runner record kept in the
|
|
174
|
+
// pending row: { nonce, branch, core_ref, dispatched_at }.
|
|
175
|
+
async function dispatchRun(ver, { cfg, fetchImpl, readTarball = readVersionTarball, coreRef = currentCoreRef, archive, now = () => new Date() } = {}) {
|
|
176
|
+
const sha = coreRef();
|
|
177
|
+
await ensureCoreSnapshot(cfg, sha, { fetchImpl, archive });
|
|
118
178
|
const tgz = await readTarball(ver);
|
|
119
179
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
120
180
|
const branch = `${BRANCH_PREFIX}${nonce}`;
|
|
121
|
-
|
|
122
|
-
const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: tgz.toString('base64'), encoding: 'base64' }, { fetchImpl });
|
|
123
|
-
const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: 'module.tgz', mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
|
|
124
|
-
const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message: `module tests: version ${ver.id}`, tree: tree.sha, parents: [] }, { fetchImpl });
|
|
125
|
-
await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
|
|
181
|
+
await pushOrphanBranch(cfg, branch, 'module.tgz', tgz, `module tests: version ${ver.id}`, { fetchImpl });
|
|
126
182
|
try {
|
|
127
|
-
await gh(cfg, 'POST',
|
|
183
|
+
await gh(cfg, 'POST', `/repos/${cfg.repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
|
|
128
184
|
ref: cfg.ref || 'main',
|
|
129
|
-
inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce,
|
|
185
|
+
inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce, core_ref: sha },
|
|
130
186
|
}, { fetchImpl });
|
|
131
187
|
} catch (e) {
|
|
132
188
|
await deleteBranch(cfg, branch, { fetchImpl });
|
|
133
189
|
throw e;
|
|
134
190
|
}
|
|
135
|
-
return { nonce, branch, dispatched_at: now().toISOString() };
|
|
191
|
+
return { nonce, branch, core_ref: sha, dispatched_at: now().toISOString() };
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
async function deleteBranch(cfg, branch, { fetchImpl } = {}) {
|
|
@@ -162,7 +218,7 @@ async function maybeDispatch(ver, { db, env = process.env, log = console, ...opt
|
|
|
162
218
|
if (newest && !(newest.outcome === 'pending' && !(newest.detail && newest.detail.runner))) return null;
|
|
163
219
|
let runner;
|
|
164
220
|
try { runner = await dispatchRun(ver, { cfg, ...opts }); }
|
|
165
|
-
catch (e) { log.
|
|
221
|
+
catch (e) { log.error(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
|
|
166
222
|
await db.query(
|
|
167
223
|
`INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
|
|
168
224
|
VALUES ($1, 'tests', 'pending', NULL, NULL, $2, $3)`,
|
|
@@ -355,4 +411,4 @@ if (require.main === module) {
|
|
|
355
411
|
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
|
|
356
412
|
}
|
|
357
413
|
|
|
358
|
-
module.exports = { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
|
|
414
|
+
module.exports = { runnerConfig, archiveCore, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
|
package/scripts/gds/module.js
CHANGED
|
@@ -452,14 +452,16 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null, del
|
|
|
452
452
|
return out.join('\n');
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('VERSION', 9)}${pad('SOURCE', 10)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
|
|
455
|
+
out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('VERSION', 9)}${pad('SOURCE', 10)}${pad('CATEGORY', 28)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
|
|
456
456
|
for (const r of shown) {
|
|
457
457
|
const mark = r.enabled ? '[x]' : '[ ]';
|
|
458
458
|
// An inferred credit is marked so it never reads as something the author
|
|
459
459
|
// typed — the catalog's whole job is honest attribution (ADR 0107 §2).
|
|
460
460
|
const suffix = r.credit.inferred ? '*' : '';
|
|
461
461
|
const upkeep = ((r.maintenance && r.maintenance.status) || 'undeclared') + (delisted.has(r.key) ? ' · DELISTED' : '');
|
|
462
|
-
|
|
462
|
+
// The parent key, plus the sub-category once one is approved (ADR 0342).
|
|
463
|
+
const cat = r.category ? r.category.parent + (r.category.sub ? `/${r.category.sub}` : '') : '—';
|
|
464
|
+
out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip(cat, 27), 28)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
|
|
463
465
|
}
|
|
464
466
|
out.push('');
|
|
465
467
|
out.push(' [x] enabled here · [ ] available, not enabled');
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.152'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
const fs = require('node:fs');
|
|
31
31
|
const path = require('node:path');
|
|
32
32
|
const { deriveProvenance } = require('./provenance');
|
|
33
|
+
const { CATEGORY_PARENTS } = require('./manifest-schema');
|
|
33
34
|
|
|
34
35
|
// The core's own published + source package names. A checkout whose package.json
|
|
35
36
|
// says one of these IS the core, so its modules/ IS the catalog (see
|
|
@@ -99,6 +100,17 @@ function creditFor(manifest = {}, { source = 'instance', coreProjectName = CORE_
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// Where a module is browsed (ADR 0342): its parent key, that parent's label, and its
|
|
104
|
+
// sub-category key — or null when the manifest declares none. The label is resolved
|
|
105
|
+
// HERE so the hall and the CLI never keep their own copy of the parent list. A parent
|
|
106
|
+
// outside the closed list cannot reach this (the validator refuses it), so an unknown
|
|
107
|
+
// one is null rather than shown under a label nobody chose.
|
|
108
|
+
function categoryFor(manifest = {}) {
|
|
109
|
+
const c = manifest.category;
|
|
110
|
+
if (!c || typeof c !== 'object' || !Object.hasOwn(CATEGORY_PARENTS, c.parent)) return null;
|
|
111
|
+
return { parent: c.parent, parentTitle: CATEGORY_PARENTS[c.parent], sub: c.sub || null };
|
|
112
|
+
}
|
|
113
|
+
|
|
102
114
|
// One catalog row per discovered module. `loaded` is the loader's
|
|
103
115
|
// discoverModules().loaded ([{ key, dir, manifest }]); `enabled` is the resolved
|
|
104
116
|
// enablement map (src/modules.js modules()).
|
|
@@ -138,6 +150,7 @@ function buildCatalog({
|
|
|
138
150
|
license: manifest.license || null,
|
|
139
151
|
maintainer: manifest.maintainer || null,
|
|
140
152
|
maintenance: manifest.maintenance || null,
|
|
153
|
+
category: categoryFor(manifest),
|
|
141
154
|
};
|
|
142
155
|
})
|
|
143
156
|
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
@@ -160,7 +173,8 @@ function instanceRows(rows = []) {
|
|
|
160
173
|
function searchCatalog(rows = [], query = '') {
|
|
161
174
|
const q = String(query || '').trim().toLowerCase();
|
|
162
175
|
if (!q) return rows;
|
|
163
|
-
return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license
|
|
176
|
+
return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license,
|
|
177
|
+
r.category && r.category.parentTitle, r.category && r.category.sub]
|
|
164
178
|
.some((f) => f && String(f).toLowerCase().includes(q)));
|
|
165
179
|
}
|
|
166
180
|
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
// // actually declares in `contributes`, and every entry must be
|
|
54
54
|
// // one it actually lists there — a typo'd exemption is an error,
|
|
55
55
|
// // never a silent pass.
|
|
56
|
+
// "category": { // OPTIONAL. Where the module is browsed and ranked (ADR 0342).
|
|
57
|
+
// "parent": "money", // REQUIRED in-block: one of the eight CATEGORY_PARENTS keys.
|
|
58
|
+
// "sub": "stripe-payments" // OPTIONAL. An approved sub-category key — the comparable set
|
|
59
|
+
// }, // the store's cap of ten counts within; absent while pending.
|
|
56
60
|
// "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
|
|
57
61
|
// "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
|
|
58
62
|
// }, // never graded and never required — the file is the how-to.
|
|
@@ -106,6 +110,24 @@ const SPEND_KEYS = ['requiresPayer'];
|
|
|
106
110
|
// The `howto` block (ADR 0347 D3) — closed for the same reason; not exported either.
|
|
107
111
|
const HOWTO_KEYS = ['artifactUrl'];
|
|
108
112
|
|
|
113
|
+
// The eight fixed parent categories (ADR 0342 D1), key → the label a catalog shows.
|
|
114
|
+
// A parent is for browsing only: the cap of ten counts within a SUB-category, never a
|
|
115
|
+
// parent (D2). The list is closed so a typo'd "monye" fails loud rather than filing
|
|
116
|
+
// the module under a ninth parent nobody browses; changing it needs a new ADR.
|
|
117
|
+
// `sub` is shape-checked only — which sub-categories are approved is ledger state
|
|
118
|
+
// (task 1004355), not something a pure validator can know.
|
|
119
|
+
const CATEGORY_PARENTS = Object.freeze({
|
|
120
|
+
work: 'Work & planning',
|
|
121
|
+
people: 'Team & people',
|
|
122
|
+
quality: 'Quality & review',
|
|
123
|
+
money: 'Money',
|
|
124
|
+
communication: 'Communication',
|
|
125
|
+
pages: 'Pages & design',
|
|
126
|
+
hosting: 'Hosting & deploy',
|
|
127
|
+
knowledge: 'Knowledge',
|
|
128
|
+
});
|
|
129
|
+
const CATEGORY_KEYS = ['parent', 'sub'];
|
|
130
|
+
|
|
109
131
|
// An https link on claude.ai (or a subdomain). Shape only: the store never fetches it.
|
|
110
132
|
function isClaudeArtifactUrl(v) {
|
|
111
133
|
if (!isStr(v)) return false;
|
|
@@ -320,6 +342,23 @@ function validateManifest(obj) {
|
|
|
320
342
|
}
|
|
321
343
|
}
|
|
322
344
|
|
|
345
|
+
// --- optional category (ADR 0342) ---
|
|
346
|
+
if ('category' in obj) {
|
|
347
|
+
const c = obj.category;
|
|
348
|
+
const parents = Object.keys(CATEGORY_PARENTS);
|
|
349
|
+
if (!isObj(c)) E('category: must be an object (e.g. { "parent": "money", "sub": "stripe-payments" })');
|
|
350
|
+
else {
|
|
351
|
+
for (const k of Object.keys(c)) {
|
|
352
|
+
if (!CATEGORY_KEYS.includes(k)) E(`category.${k}: unknown key (allowed: ${CATEGORY_KEYS.join(', ')}) — a module has exactly one sub-category (ADR 0342 D4)`);
|
|
353
|
+
}
|
|
354
|
+
if (!isStr(c.parent)) E(`category.parent: required — one of ${parents.join(', ')}`);
|
|
355
|
+
else if (!parents.includes(c.parent)) E(`category.parent: "${c.parent}" is not one of ${parents.join(', ')} (ADR 0342 D1)`);
|
|
356
|
+
if ('sub' in c && (!isStr(c.sub) || !KEY_RE.test(c.sub))) {
|
|
357
|
+
E('category.sub: must be a sub-category key (lowercase kebab-case) when present — leave it out while a new one is pending approval');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
323
362
|
// --- optional how-to extras (ADR 0347 D3) ---
|
|
324
363
|
if ('howto' in obj) {
|
|
325
364
|
const h = obj.howto;
|
|
@@ -370,5 +409,5 @@ function assertValidManifest(obj, label = 'module.json') {
|
|
|
370
409
|
|
|
371
410
|
module.exports = {
|
|
372
411
|
validateManifest, assertValidManifest, declaredEntries, entryLabel, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
|
|
373
|
-
MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
|
|
412
|
+
MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES, CATEGORY_PARENTS,
|
|
374
413
|
};
|
|
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
|
|
|
58
58
|
assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
|
|
59
59
|
assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
|
|
60
60
|
assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
|
|
61
|
-
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
|
|
61
|
+
assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{categoryFact\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, (task 1003784) the category, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
|
|
62
62
|
assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
|
|
63
63
|
const css = read('modules.css');
|
|
64
64
|
assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
|
|
@@ -84,6 +84,9 @@ test('modules: upkeep colours the exception — undeclared is the warn pill, mai
|
|
|
84
84
|
assert.match(js, /<span class="ov-fact">upkeep <b>\$\{escapeHtml\(status\)\}<\/b><\/span>/, 'a maintained module reads as a plain fact');
|
|
85
85
|
assert.match(js, /fact-pill fact-pill--info">core-loaded</, 'core-loaded is the info pill');
|
|
86
86
|
assert.match(js, /<span class="fact-chip">\$\{escapeHtml\(m\.license\)\}<\/span>/, 'the licence is a chip');
|
|
87
|
+
assert.match(js, /<span class="ov-fact">in <b>\$\{escapeHtml\(c\.parentTitle\)\}<\/b>\$\{sub\}<\/span>/,
|
|
88
|
+
'the category is a plain fact, labelled from the server row (task 1003784)');
|
|
89
|
+
assert.match(js, /cat\.parentTitle, cat\.sub\]/, 'the hall search covers the category, as searchCatalog does');
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
test('modules: the kit filter bar carries the search and a state lens, and the empty state is the kit\'s', () => {
|
package/tests/module_catalog.mjs
CHANGED
|
@@ -330,3 +330,37 @@ test('detectRoots: an explicit root bypasses the memo in both directions', () =>
|
|
|
330
330
|
// …nor poison the memo for every later caller that asks about this process.
|
|
331
331
|
assert.equal(catalog.detectRoots(), cached);
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// category — the ADR 0342 field, carried by the ONE model to both surfaces (task 1003784)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
const categorized = () => catalog.buildCatalog({
|
|
339
|
+
loaded: [
|
|
340
|
+
loadedIn(CORE, 'economy', { category: { parent: 'money' } }),
|
|
341
|
+
loadedIn(CORE, 'discord', { category: { parent: 'communication', sub: 'team-chat' } }),
|
|
342
|
+
loadedIn(INST, 'game'),
|
|
343
|
+
],
|
|
344
|
+
coreRoot: CORE, instanceRoot: INST,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('buildCatalog: a row carries its category with the parent label; none declared is null', () => {
|
|
348
|
+
const byKey = Object.fromEntries(categorized().map((r) => [r.key, r]));
|
|
349
|
+
assert.deepEqual(byKey.economy.category, { parent: 'money', parentTitle: 'Money', sub: null });
|
|
350
|
+
assert.deepEqual(byKey.discord.category, { parent: 'communication', parentTitle: 'Communication', sub: 'team-chat' });
|
|
351
|
+
assert.equal(byKey.game.category, null);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('searchCatalog: finds a module by its category label or sub-category', () => {
|
|
355
|
+
const rows = categorized();
|
|
356
|
+
assert.deepEqual(catalog.searchCatalog(rows, 'money').map((r) => r.key), ['economy']);
|
|
357
|
+
assert.deepEqual(catalog.searchCatalog(rows, 'team-chat').map((r) => r.key), ['discord']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('formatCatalog: the CATEGORY column shows parent/sub, and a dash when none is declared', () => {
|
|
361
|
+
const out = mod.formatCatalog(categorized());
|
|
362
|
+
assert.match(out, /CATEGORY/);
|
|
363
|
+
assert.match(out, /discord\s+\S+\s+core\s+communication\/team-chat/);
|
|
364
|
+
assert.match(out, /economy\s+\S+\s+core\s+money\s/);
|
|
365
|
+
assert.match(out, /game\s+\S+\s+instance\s+—\s/);
|
|
366
|
+
});
|
|
@@ -392,3 +392,39 @@ test('howto.artifactUrl: anything but an https claude.ai link is refused, as is
|
|
|
392
392
|
.some((e) => e.startsWith('howto.artifactURL: unknown key')));
|
|
393
393
|
assert.equal(validateManifest({ ...baseManifest(), howto: 'https://claude.ai/x' }).valid, false);
|
|
394
394
|
});
|
|
395
|
+
|
|
396
|
+
// --- category (ADR 0342, task 1003784) ---
|
|
397
|
+
|
|
398
|
+
test('category: optional; a known parent, with or without a sub-category, is accepted', () => {
|
|
399
|
+
assert.equal(validateManifest(baseManifest()).valid, true, 'a manifest with no category still validates');
|
|
400
|
+
for (const category of [{ parent: 'money' }, { parent: 'communication', sub: 'team-chat' }]) {
|
|
401
|
+
const r = validateManifest({ ...baseManifest(), category });
|
|
402
|
+
assert.equal(r.valid, true, `${JSON.stringify(category)}: ${r.errors.join('; ')}`);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('category: a parent outside the eight, a missing parent, a bad sub or an unknown key fails loud', () => {
|
|
407
|
+
const errs = (category) => validateManifest({ ...baseManifest(), category }).errors;
|
|
408
|
+
assert.ok(errs({ parent: 'monye' }).some((e) => e.startsWith('category.parent: "monye" is not one of')), 'a typo names the list');
|
|
409
|
+
assert.ok(errs({ sub: 'stripe-payments' }).some((e) => e.startsWith('category.parent: required')));
|
|
410
|
+
assert.ok(errs({ parent: 'money', sub: 'Stripe Payments' }).some((e) => e.startsWith('category.sub:')));
|
|
411
|
+
assert.ok(errs({ parent: 'money', sub: '' }).some((e) => e.startsWith('category.sub:')));
|
|
412
|
+
assert.ok(errs({ parent: 'money', subs: ['a', 'b'] }).some((e) => e.startsWith('category.subs: unknown key')),
|
|
413
|
+
'one sub-category per module (D4) — a second field is a typo, not a list');
|
|
414
|
+
assert.equal(validateManifest({ ...baseManifest(), category: 'money' }).valid, false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('category: the eight parents are exactly ADR 0342 D1', () => {
|
|
418
|
+
const { CATEGORY_PARENTS } = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
|
|
419
|
+
assert.deepEqual(Object.keys(CATEGORY_PARENTS),
|
|
420
|
+
['work', 'people', 'quality', 'money', 'communication', 'pages', 'hosting', 'knowledge']);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('category: every bundled module declares one (core modules get categories too, ADR 0342)', async () => {
|
|
424
|
+
const fs = await import('node:fs');
|
|
425
|
+
const dir = path.join(ROOT, 'modules');
|
|
426
|
+
const missing = fs.readdirSync(dir)
|
|
427
|
+
.filter((k) => fs.existsSync(path.join(dir, k, 'module.json')))
|
|
428
|
+
.filter((k) => !JSON.parse(fs.readFileSync(path.join(dir, k, 'module.json'), 'utf8')).category);
|
|
429
|
+
assert.deepEqual(missing, [], `declare "category": { "parent": … } in: ${missing.join(', ')}`);
|
|
430
|
+
});
|