@bongos/core 1.20.70 → 1.20.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.20.70",
6
- "core_contract": "1.20.70",
7
- "source_commit": "31aa1da4c574b3429cbe3bea1cee188486c0845b",
5
+ "core_version": "1.20.71",
6
+ "core_contract": "1.20.71",
7
+ "source_commit": "80b594f7c46b42161be5a9453989b09495783332",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T14:41:01.807Z",
9
+ "built_at": "2026-10-01T15:04:18.463Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 572,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2795,
14
+ "functional_verbatim": 2798,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3395,
20
- "tree_sha256": "ba829522471ea62d370430e52214b3298f81333cef684d98d98231286a345e34",
19
+ "file_count": 3398,
20
+ "tree_sha256": "5a489808d7d33aa65f957bcb1aa21323044cafe05a72d5d7293b255966c007b9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2272,7 +2272,7 @@
2272
2272
  {
2273
2273
  "path": "docs/architecture.md",
2274
2274
  "mode": "0000644",
2275
- "sha256": "8b0fe800a7b4d60f4cb7f5eccd4fcab98d3e080a978ba3125dd9171c63cb5c78"
2275
+ "sha256": "3f0c53c83d5faed4198a251f09c3ffa057aa3dfaec9912d5fc884b0f1d602ebe"
2276
2276
  },
2277
2277
  {
2278
2278
  "path": "docs/branding-contract.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "82e53cbebb5b042447d74679e2501c04a79e55d473e4cb3976578572152d5cf6"
2805
+ "sha256": "487634be3dd64633b2ee526720bff74171601bc1e58c02cfd605914bf8a9984b"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -9457,12 +9457,12 @@
9457
9457
  {
9458
9458
  "path": "package-lock.json",
9459
9459
  "mode": "0000644",
9460
- "sha256": "ce7e8e4e85602173606cdf3d320047cb41654ab0662ea2840537a7f10b4dfab4"
9460
+ "sha256": "795a529e4e0cfa999a1e07d36b360e982ad712026d571679b59ca7202ffcb740"
9461
9461
  },
9462
9462
  {
9463
9463
  "path": "package.json",
9464
9464
  "mode": "0000644",
9465
- "sha256": "f82ba870c1432fd2fd421397df493f3c052bab48a920d403558364ccd720a109"
9465
+ "sha256": "e3a6640cc49059ed01f82ddb1212d14d65f76e562d066aca5ff55c64117b9d19"
9466
9466
  },
9467
9467
  {
9468
9468
  "path": "public-docs/index.html",
@@ -9482,7 +9482,7 @@
9482
9482
  {
9483
9483
  "path": "release-notes.json",
9484
9484
  "mode": "0000644",
9485
- "sha256": "936955084e861a2c76032f3219e888b6c83c137c2ad6f0ba744ad7f65c5492a3"
9485
+ "sha256": "ae2119391f14b5e1fda111e28b6cab08dd5ecb86826150cac145196b2f81b140"
9486
9486
  },
9487
9487
  {
9488
9488
  "path": "scripts/bongos-mcp.js",
@@ -10329,15 +10329,25 @@
10329
10329
  "mode": "0000644",
10330
10330
  "sha256": "7b10d8fd5146325c218d59eadfb6feaeb8819e14c4b27b166ad9ff714e1caed0"
10331
10331
  },
10332
+ {
10333
+ "path": "scripts/gds/module-assess-score.js",
10334
+ "mode": "0000644",
10335
+ "sha256": "526f3852778e708c6ffe4d6e0106a1617113e2e49aac8754b24321a68cddfaac"
10336
+ },
10332
10337
  {
10333
10338
  "path": "scripts/gds/module-assess-security.js",
10334
10339
  "mode": "0000644",
10335
- "sha256": "0dad366dcabf453ae276e074aafa6bd71223853a31cfe005dac6f3d713e12e4a"
10340
+ "sha256": "54a6c9c7ab8d0401eb5adf8b0025eac56f9c907ea19a868a38eb0d207befa058"
10336
10341
  },
10337
10342
  {
10338
10343
  "path": "scripts/gds/module-assess-tests.js",
10339
10344
  "mode": "0000644",
10340
- "sha256": "b86d6b3b98534c91b2dffe67f6c5335a09d2ef7c571c70e3a0871ab4929bc490"
10345
+ "sha256": "83dc9529be926f8e10043ab78d644d1a72096d02000623035798bd420b2b3261"
10346
+ },
10347
+ {
10348
+ "path": "scripts/gds/module-assess-version.js",
10349
+ "mode": "0000644",
10350
+ "sha256": "5891fdb5c464280801fbf401001d375ba93d5598d2190a9854f6dd2d61e0521b"
10341
10351
  },
10342
10352
  {
10343
10353
  "path": "scripts/gds/module.js",
@@ -11587,7 +11597,7 @@
11587
11597
  {
11588
11598
  "path": "src/bongos/routes/modules.js",
11589
11599
  "mode": "0000644",
11590
- "sha256": "abf74a67f9706eed2d8134c8bff0e1ab55831493aeb4217e5a0f16326e85c64b"
11600
+ "sha256": "65dcf7b704d1fa321c128b36f3147bf23f54b976f60980450b2ed8a8583a302a"
11591
11601
  },
11592
11602
  {
11593
11603
  "path": "src/bongos/routes/my-sessions.js",
@@ -11662,7 +11672,7 @@
11662
11672
  {
11663
11673
  "path": "src/module-api.js",
11664
11674
  "mode": "0000644",
11665
- "sha256": "4ac89563e04b58f5b2a3662990aeb1916f614ba099fe1414f10a51865168cb48"
11675
+ "sha256": "93d4c7e2e57b155565f9e995e8fe091e8c1782254ea9d06d29367fbcb404b1bb"
11666
11676
  },
11667
11677
  {
11668
11678
  "path": "src/module-loader/catalog.js",
@@ -14714,6 +14724,11 @@
14714
14724
  "mode": "0000644",
14715
14725
  "sha256": "9183d78e4d195428afbee8c71746e12db2455f8529ac1223d690a7b8f7ded538"
14716
14726
  },
14727
+ {
14728
+ "path": "tests/module_assess_score.mjs",
14729
+ "mode": "0000644",
14730
+ "sha256": "fd919118055c14147c9b4d7649622c1ebb47ba48336d84fe2c2a35f859a20dd6"
14731
+ },
14717
14732
  {
14718
14733
  "path": "tests/module_assess_security.mjs",
14719
14734
  "mode": "0000644",
@@ -273,7 +273,7 @@ module_assessment_scores(id, version_id, kind IN (computed|override), overall 0-
273
273
  -- and is a new row on the computed history, never an edit (D6)
274
274
  ```
275
275
 
276
- Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. Nothing calls it on publish yet (task 1003794 wires that). **Security** — `scripts/gds/module-assess-security.js <version id>` (task 1003792, ADR 0343 D2 gate) appends one `security` row, `passed`/`failed` only: fails on a publish-denylist file, a `module.json` dependency fetched outside the registry (an allowlist: a plain npm name with a plain semver range or dist-tag, anything else fails and is never handed to npm), or a high/critical `npm audit` advisory (resolved metadata-only with `--ignore-scripts`; nothing of the module runs, so it is control-plane safe). An audit that cannot run is `not_scored` — the gate stays shut. Floating ranges and the `maintenance` posture (ADR 0166) are noted in `detail`, never failed on.
276
+ Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. On publish the version's Tests part is recorded `pending` until that environment runs it. **Security** — `scripts/gds/module-assess-security.js <version id>` (task 1003792, ADR 0343 D2 gate) appends one `security` row, `passed`/`failed` only: fails on a publish-denylist file, a `module.json` dependency fetched outside the registry (an allowlist: a plain npm name with a plain semver range or dist-tag, anything else fails and is never handed to npm), or a high/critical `npm audit` advisory (resolved metadata-only with `--ignore-scripts`; nothing of the module runs, so it is control-plane safe). An audit that cannot run is `not_scored` — the gate stays shut. Floating ranges and the `maintenance` posture (ADR 0166) are noted in `detail`, never failed on. **Score** — `scripts/gds/module-assess-score.js` (task 1003794) composes a version's newest signal per part into a `module_assessment_scores` row: `security_passed` from the gate (NULL = not run), `overall` = the plain average of the SCORED parts among Tests, Install, Reliability and Tester feedback (a part with no data is left out, never zero; none → NULL), Docs shown but never averaged, `is_new` until Install or Reliability is scored, plus the signal ids and a readable `formula`. A recompose that would repeat the current score writes nothing. Both signal CLIs recompose after recording, and the store's publish route queues `assessVersion` after answering the author (one at a time per process, at most 20 waiting; past that it is logged as not assessed): Tests `pending` (only if no Tests result exists), the Security gate, then the score. `node scripts/gds/module-assess-version.js <id>` re-runs it by hand (`scripts/gds/module-assess-version.js` holds the publish-time half, apart from the composer so the signal CLIs recompose without a require cycle).
277
277
 
278
278
  Code: `src/bongos/module-entitlements.js` (`grantEntitlement`, `recordAcquired`, `revokeEntitlement`, `checkEntitlement`, `listEntitlements`). Read routes (own-scoped, `requireBuilder`): `GET /store/entitlements`, `GET /store/modules/:key/entitlement`. Install (task 1003785) grants a free module through `POST /store/modules/:key/acquire`; the buy action (area 8) will grant a paid one.
279
279
 
@@ -2797,5 +2797,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2797
2797
  landed since 1.20.68 with no explicit bump. run 36873353617. (task 1002620)
2798
2798
  1.20.70 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2799
2799
  landed since 1.20.69 with no explicit bump. run 36877499272. (task 1002620)
2800
+ 1.20.71 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2801
+ landed since 1.20.70 with no explicit bump. run 36881290177. (task 1002620)
2800
2802
  ---------------------------------------------------------------------------
2801
2803
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.70",
3
+ "version": "1.20.71",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.70",
9
+ "version": "1.20.71",
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.20.70",
3
+ "version": "1.20.71",
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",
@@ -8549,5 +8549,11 @@
8549
8549
  "id": "1003787",
8550
8550
  "text": "Running 'bongos upgrade' now also tells you which installed modules have a newer version in the store, and the command to update each one. It never stops an upgrade on that account."
8551
8551
  }
8552
+ ],
8553
+ "1.20.71": [
8554
+ {
8555
+ "id": "1003794",
8556
+ "text": "Each module version in the store now gets one overall score built from its separate checks, and a newly published version is checked automatically. A version with no real-world data yet is marked New instead of scoring low."
8557
+ }
8552
8558
  ]
8553
8559
  }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-score.js — compose a store version's assessment
3
+ // signals into its one overall score (task 1003794; ADR 0343 D2/D3/D5, ADR 0347 D5; tables from core_265).
4
+ //
5
+ // WHY one composer. The floor, the ranking and the cap (tasks 1003807-9) all read
6
+ // one number, and an author appealing it must be able to redo the sum. So the rule
7
+ // lives here once, as a pure function, and every writer of a signal calls the same
8
+ // recompose afterwards:
9
+ // - Security is a GATE (D2): it sets security_passed (true / false / NULL = not
10
+ // run or could not run) and is never averaged in.
11
+ // - overall = the plain, unweighted average of the parts that are SCORED among
12
+ // Tests, Install, Reliability and Tester feedback (D3). A part with no data —
13
+ // no row, or no_data / pending / not_scored — is left out, never a zero.
14
+ // No such part at all → overall NULL.
15
+ // - Docs is shown with the parts and never averaged (ADR 0347 D5).
16
+ // - is_new (D5): the version has no scored Install or Reliability part yet.
17
+ // The CURRENT result of a part is its newest signal row (core_265). Each score
18
+ // row names the signal ids it was composed from and a formula a person can read,
19
+ // so "why did I score 71?" is answered by the row. A recompose that would write
20
+ // the same score as the current computed row writes nothing, so the history only
21
+ // moves when the answer does.
22
+ //
23
+ // Re-assessment on publish lives in module-assess-version.js, which runs the
24
+ // Security gate and then this recompose; it is a separate file so the signal
25
+ // CLIs can call recompose without a require cycle.
26
+ //
27
+ // Both reads are served by core_265's indexes: signals on (version_id, part,
28
+ // measured_at DESC, id DESC), scores on (version_id, computed_at DESC, id DESC).
29
+ //
30
+ // node scripts/gds/module-assess-score.js <store_module_versions.id> recompose + print
31
+
32
+ const AVERAGED = ['tests', 'install', 'reliability', 'tester_feedback'];
33
+ const REAL_WORLD = ['install', 'reliability'];
34
+ const LABEL = { security: 'Security', tests: 'Tests', install: 'Install', reliability: 'Reliability', tester_feedback: 'Tester feedback', docs: 'Docs' };
35
+
36
+ // Compose from the CURRENT signal per part ({ [part]: row }), pure. Returns the
37
+ // fields of a module_assessment_scores row.
38
+ function composeScore(current = {}) {
39
+ const sec = current.security;
40
+ const securityPassed = sec && sec.outcome === 'passed' ? true : sec && sec.outcome === 'failed' ? false : null;
41
+ const used = AVERAGED.filter((p) => current[p] && current[p].outcome === 'scored' && current[p].score != null);
42
+ const overall = used.length
43
+ ? Math.round(used.reduce((sum, p) => sum + Number(current[p].score), 0) / used.length)
44
+ : null;
45
+ const isNew = !REAL_WORLD.some((p) => used.includes(p));
46
+
47
+ const gate = securityPassed === true ? 'Security passed' : securityPassed === false ? 'Security FAILED (not listed, whatever the score)' : 'Security not checked yet';
48
+ const sum = used.length
49
+ ? `overall ${overall} = average of ${used.map((p) => `${LABEL[p]} ${current[p].score}`).join(', ')}`
50
+ : 'no overall score yet: no averaged part has data';
51
+ const left = AVERAGED.filter((p) => !used.includes(p));
52
+ const formula = `${gate}; ${sum}${left.length ? `; left out (no data): ${left.map((p) => LABEL[p]).join(', ')}` : ''}${isNew ? '; labelled New' : ''}`;
53
+
54
+ const signalIds = [sec, ...used.map((p) => current[p])].filter(Boolean).map((r) => Number(r.id)).sort((a, b) => a - b);
55
+ return { overall, security_passed: securityPassed, is_new: isNew, formula, signal_ids: signalIds };
56
+ }
57
+
58
+ const sameScore = (a, b) => !!a && !!b
59
+ && (a.overall == null ? null : Number(a.overall)) === b.overall
60
+ && a.security_passed === b.security_passed
61
+ && a.is_new === b.is_new
62
+ && (a.signal_ids || []).map(Number).join(',') === b.signal_ids.join(',');
63
+
64
+ // Read a version's current signal per part, compose, and append a computed score
65
+ // row unless it would repeat the current one. Resolves { ok, score, written }.
66
+ async function recomposeScore(versionId, { db } = {}) {
67
+ const pool = db || require('../../src/bongos/pool').pool;
68
+ const { rows } = await pool.query(
69
+ `SELECT DISTINCT ON (part) id, part, outcome, score, sample_size
70
+ FROM module_assessment_signals WHERE version_id = $1
71
+ ORDER BY part, measured_at DESC, id DESC`, [versionId]);
72
+ const current = {};
73
+ for (const r of rows) current[r.part] = r;
74
+ const next = composeScore(current);
75
+
76
+ const { rows: [last] } = await pool.query(
77
+ `SELECT id, overall, security_passed, is_new, signal_ids FROM module_assessment_scores
78
+ WHERE version_id = $1 AND kind = 'computed' ORDER BY computed_at DESC, id DESC LIMIT 1`, [versionId]);
79
+ if (sameScore(last, next)) return { ok: true, score: { ...last, formula: next.formula }, written: false };
80
+
81
+ const { rows: [row] } = await pool.query(
82
+ `INSERT INTO module_assessment_scores (version_id, kind, overall, security_passed, is_new, formula, signal_ids)
83
+ VALUES ($1, 'computed', $2, $3, $4, $5, $6)
84
+ RETURNING id, version_id, overall, security_passed, is_new, formula, signal_ids, computed_at`,
85
+ [versionId, next.overall, next.security_passed, next.is_new, next.formula, next.signal_ids]);
86
+ return { ok: true, score: row, written: true };
87
+ }
88
+
89
+ // After a signal CLI records a row: re-compose and print the score line.
90
+ async function printRecomposed(versionId) {
91
+ const { score } = await recomposeScore(versionId);
92
+ console.log(` score: ${score.formula}`);
93
+ }
94
+
95
+ async function main(argv) {
96
+ const id = argv[0];
97
+ if (!/^\d+$/.test(String(id || ''))) {
98
+ console.error('usage: node scripts/gds/module-assess-score.js <store_module_versions.id>');
99
+ return 2;
100
+ }
101
+ const res = await recomposeScore(id);
102
+ if (!res.ok) { console.error(res.message); return 1; }
103
+ console.log(`version ${id}: ${res.score.formula}`);
104
+ return 0;
105
+ }
106
+
107
+ if (require.main === module) {
108
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
109
+ }
110
+
111
+ module.exports = { composeScore, recomposeScore, printRecomposed };
@@ -241,6 +241,7 @@ async function main(argv) {
241
241
  if (!res.ok) { console.error(res.message); return 1; }
242
242
  const s = res.signal;
243
243
  console.log(`${res.version.module_key} ${res.version.version}: security ${s.outcome} — ${s.reason} (signal ${s.id})`);
244
+ await require('./module-assess-score').printRecomposed(res.version.id); // new data re-composes the score (task 1003794)
244
245
  return 0;
245
246
  }
246
247
 
@@ -238,6 +238,7 @@ async function main(argv) {
238
238
  if (!res.ok) { console.error(res.message); return 1; }
239
239
  const s = res.signal;
240
240
  console.log(`${res.version.module_key} ${res.version.version}: tests ${s.outcome}${s.score !== null ? ` ${s.score}/100` : ''} — ${s.reason} (signal ${s.id})`);
241
+ await require('./module-assess-score').printRecomposed(res.version.id); // new data re-composes the score (task 1003794)
241
242
  return 0;
242
243
  }
243
244
 
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-version.js — re-assess a store version when it is
3
+ // published: record Tests as pending, run the Security gate, compose the score
4
+ // (task 1003794; ADR 0343; tables from core_265).
5
+ //
6
+ // WHY. A new version is a new thing to judge, so the store's publish route queues
7
+ // this after it answers the author. Tests is recorded `pending`, and only when the
8
+ // version has no Tests result yet: a published module's tests run only in the
9
+ // separate test environment, never on the control plane (module-assess-tests.js).
10
+ // The Security gate reads the tarball and asks npm about the declared dependencies
11
+ // — it never runs the module's code, so it is safe here. Then the score is
12
+ // composed (module-assess-score.js). Install, Reliability and Tester feedback
13
+ // arrive later from their own tasks; each recompose picks them up.
14
+ //
15
+ // The route goes through queueAssessment, so the web process runs one assessment
16
+ // at a time (the gate's npm resolve + audit can take two minutes) and holds at most
17
+ // QUEUE_MAX waiting; past that a publish is logged as not assessed, and the CLI
18
+ // below re-runs it. This file is apart from the composer so the signal CLIs can
19
+ // recompose without a require cycle (composer <- security, tests, this).
20
+ //
21
+ // node scripts/gds/module-assess-version.js <store_module_versions.id> assess + print
22
+
23
+ const { recomposeScore } = require('./module-assess-score');
24
+
25
+ // Re-assess one version (on publish): Tests pending, the Security gate, then the
26
+ // score. `recordSecurity` is injectable so tests need no npm. Never throws for a
27
+ // failed measurement — that is recorded as the signal's outcome — and resolves
28
+ // { ok: false, code } only when the version does not exist.
29
+ async function assessVersion(versionId, { db, recordSecurity } = {}) {
30
+ const pool = db || require('../../src/bongos/pool').pool;
31
+ const { rows: [ver] } = await pool.query(
32
+ 'SELECT id, module_key, version FROM store_module_versions WHERE id = $1', [versionId]);
33
+ if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
34
+
35
+ // Only a version with no Tests result yet: a re-assess must never hide a real
36
+ // score behind a newer "pending".
37
+ await pool.query(
38
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
39
+ SELECT $1::bigint, 'tests', 'pending', NULL, NULL, $2::text, '{}'::jsonb
40
+ WHERE NOT EXISTS (SELECT 1 FROM module_assessment_signals WHERE version_id = $1::bigint AND part = 'tests')`,
41
+ [ver.id, 'waiting for the separate test environment: a published module\'s tests never run on the control plane']);
42
+ const record = recordSecurity || require('./module-assess-security').recordSecuritySignal;
43
+ const security = await record(ver.id, { db: pool });
44
+ const composed = await recomposeScore(ver.id, { db: pool });
45
+ return { ok: true, version: ver, security: security && security.signal, score: composed.score };
46
+ }
47
+
48
+ // One assessment at a time in this process, at most QUEUE_MAX waiting. Resolves
49
+ // at once with whether the version was queued; a failed assessment is logged and
50
+ // never stops the next one. `run` is injectable for tests.
51
+ const QUEUE_MAX = 20;
52
+ let tail = Promise.resolve();
53
+ let waiting = 0;
54
+ function queueAssessment(versionId, { log = console, run = (id) => module.exports.assessVersion(id) } = {}) {
55
+ if (waiting >= QUEUE_MAX) {
56
+ log.warn({ versionId }, `assessment queue full (${QUEUE_MAX} waiting): version not assessed; run module-assess-version.js ${versionId}`);
57
+ return false;
58
+ }
59
+ waiting += 1;
60
+ tail = tail.then(() => run(versionId))
61
+ .catch((e) => { log.error({ versionId, err: e && e.message }, 'assessment after publish failed'); })
62
+ .finally(() => { waiting -= 1; });
63
+ return true;
64
+ }
65
+
66
+ async function main(argv) {
67
+ const id = argv[0];
68
+ if (!/^\d+$/.test(String(id || ''))) {
69
+ console.error('usage: node scripts/gds/module-assess-version.js <store_module_versions.id>');
70
+ return 2;
71
+ }
72
+ const res = await assessVersion(id);
73
+ if (!res.ok) { console.error(res.message); return 1; }
74
+ console.log(`version ${id}: ${res.score.formula}`);
75
+ return 0;
76
+ }
77
+
78
+ if (require.main === module) {
79
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
80
+ }
81
+
82
+ module.exports = { assessVersion, queueAssessment, QUEUE_MAX };
@@ -78,6 +78,7 @@ const modulesLib = require('../../modules');
78
78
  const moduleCli = require('../../../scripts/gds/module');
79
79
  const moduleSubmissions = require('../module-submissions');
80
80
  const moduleArtifact = require('../../../scripts/gds/module-artifact');
81
+ const moduleAssessVersion = require('../../../scripts/gds/module-assess-version');
81
82
  const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
82
83
  const {
83
84
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
@@ -393,6 +394,12 @@ module.exports = function buildModulesRouter() {
393
394
 
394
395
  console.log(`[gds] module "${key}" ${v.version} published to the store by builder ${req.builder.id}`);
395
396
  res.status(201).json({ ok: true, created_module: result.created, version: result.version });
397
+ // A new version is a new thing to judge (task 1003794, ADR 0343): queue its
398
+ // assessment after answering, since the Security gate asks npm about its
399
+ // dependencies and can take a minute. The queue runs one at a time and is
400
+ // capped; a failure is logged, never the author's error, and
401
+ // `node scripts/gds/module-assess-version.js <id>` re-runs it.
402
+ setImmediate(() => moduleAssessVersion.queueAssessment(result.version.id, { log }));
396
403
  }));
397
404
 
398
405
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
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.20.70'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.71'; // 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');
@@ -0,0 +1,276 @@
1
+ // tests/module_assess_score.mjs — composing a store version's assessment signals
2
+ // into its one overall score, and re-assessing a version when it is published
3
+ // (task 1003794; ADR 0343 D2/D3/D5, ADR 0347 D5; tables from core_265). No real
4
+ // DB and no npm: the composer is pure, the writers run against a fake pool that
5
+ // keeps core_265's append-only rows in memory, and the Security gate is injected.
6
+ //
7
+ // Run: node tests/module_assess_score.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { composeScore, recomposeScore } = require('../scripts/gds/module-assess-score.js');
18
+ const { assessVersion, queueAssessment, QUEUE_MAX } = require('../scripts/gds/module-assess-version.js');
19
+
20
+ const sig = (id, part, outcome, score = null) => ({ id, part, outcome, score });
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // composeScore — the rule, pure
24
+ // ---------------------------------------------------------------------------
25
+
26
+ test('overall is the plain average of the scored averaged parts, rounded', () => {
27
+ const s = composeScore({
28
+ security: sig(1, 'security', 'passed'),
29
+ tests: sig(2, 'tests', 'scored', 80),
30
+ install: sig(3, 'install', 'scored', 95),
31
+ reliability: sig(4, 'reliability', 'scored', 70),
32
+ });
33
+ assert.equal(s.overall, 82, '(80 + 95 + 70) / 3 = 81.67 → 82');
34
+ assert.equal(s.security_passed, true);
35
+ assert.equal(s.is_new, false);
36
+ assert.deepEqual(s.signal_ids, [1, 2, 3, 4]);
37
+ assert.match(s.formula, /Security passed; overall 82 = average of Tests 80, Install 95, Reliability 70; left out \(no data\): Tester feedback/);
38
+ });
39
+
40
+ test('a part with no data is left out, never a zero (D3)', () => {
41
+ const s = composeScore({
42
+ security: sig(1, 'security', 'passed'),
43
+ tests: sig(2, 'tests', 'scored', 60),
44
+ install: sig(3, 'install', 'no_data'),
45
+ reliability: sig(4, 'reliability', 'pending'),
46
+ tester_feedback: sig(5, 'tester_feedback', 'not_scored'),
47
+ });
48
+ assert.equal(s.overall, 60, 'only Tests counts');
49
+ assert.deepEqual(s.signal_ids, [1, 2], 'the score names only the rows it was composed from');
50
+ });
51
+
52
+ test('Security is a gate, never averaged in (D2): a failed gate keeps its parts visible', () => {
53
+ const s = composeScore({ security: sig(1, 'security', 'failed'), tests: sig(2, 'tests', 'scored', 100) });
54
+ assert.equal(s.security_passed, false);
55
+ assert.equal(s.overall, 100, 'the gate does not lower the number — it keeps the version off the listing');
56
+ assert.match(s.formula, /Security FAILED \(not listed, whatever the score\)/);
57
+ });
58
+
59
+ test('a Security check that has not run, or could not, is NULL — not a pass', () => {
60
+ assert.equal(composeScore({}).security_passed, null);
61
+ assert.equal(composeScore({ security: sig(1, 'security', 'not_scored') }).security_passed, null);
62
+ assert.match(composeScore({}).formula, /Security not checked yet/);
63
+ });
64
+
65
+ test('Docs is shown with the parts and never averaged (ADR 0347 D5)', () => {
66
+ const s = composeScore({ tests: sig(2, 'tests', 'scored', 50), docs: sig(9, 'docs', 'scored', 100) });
67
+ assert.equal(s.overall, 50);
68
+ assert.equal(s.signal_ids.includes(9), false);
69
+ });
70
+
71
+ test('no averaged part with data → overall NULL, labelled New', () => {
72
+ const s = composeScore({ security: sig(1, 'security', 'passed'), tests: sig(2, 'tests', 'pending') });
73
+ assert.equal(s.overall, null);
74
+ assert.equal(s.is_new, true);
75
+ assert.match(s.formula, /no overall score yet: no averaged part has data; .*labelled New/);
76
+ });
77
+
78
+ test('New until Install or Reliability is scored (D5) — Tests and Tester feedback alone do not lift it', () => {
79
+ assert.equal(composeScore({ tests: sig(1, 'tests', 'scored', 90), tester_feedback: sig(2, 'tester_feedback', 'scored', 90) }).is_new, true);
80
+ assert.equal(composeScore({ reliability: sig(1, 'reliability', 'scored', 40) }).is_new, false);
81
+ });
82
+
83
+ test('price is never an input: nothing in the composer reads one (D6)', () => {
84
+ for (const f of ['module-assess-score.js', 'module-assess-version.js']) {
85
+ const src = fs.readFileSync(new URL(`../scripts/gds/${f}`, import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
86
+ assert.doesNotMatch(src, /price|credits|cents/i, f);
87
+ }
88
+ });
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // recomposeScore / assessVersion — against an append-only fake
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function fakeDb({ versions = [{ id: 7, module_key: 'weather', version: '1.0.0' }] } = {}) {
95
+ const signals = [];
96
+ const scores = [];
97
+ let clock = 0;
98
+ const db = {
99
+ signals, scores,
100
+ async query(sql, params = []) {
101
+ if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
102
+ return { rows: versions.filter((v) => String(v.id) === String(params[0])) };
103
+ }
104
+ if (/SELECT DISTINCT ON \(part\)/.test(sql)) {
105
+ const cur = {};
106
+ for (const s of signals.filter((x) => String(x.version_id) === String(params[0]))) {
107
+ if (!cur[s.part] || s.t > cur[s.part].t || (s.t === cur[s.part].t && s.id > cur[s.part].id)) cur[s.part] = s;
108
+ }
109
+ return { rows: Object.values(cur) };
110
+ }
111
+ if (/FROM module_assessment_scores/.test(sql) && /^SELECT/.test(sql.trim())) {
112
+ const mine = scores.filter((x) => String(x.version_id) === String(params[0]) && x.kind === 'computed');
113
+ return { rows: mine.length ? [mine[mine.length - 1]] : [] };
114
+ }
115
+ if (/INSERT INTO module_assessment_scores/.test(sql)) {
116
+ const row = { id: scores.length + 1, version_id: params[0], kind: 'computed', overall: params[1], security_passed: params[2], is_new: params[3], formula: params[4], signal_ids: params[5].map(String) };
117
+ scores.push(row); return { rows: [row] };
118
+ }
119
+ if (/INSERT INTO module_assessment_signals/.test(sql) && /'pending'/.test(sql)) {
120
+ if (signals.some((s) => String(s.version_id) === String(params[0]) && s.part === 'tests')) return { rows: [] };
121
+ signals.push({ id: signals.length + 1, version_id: params[0], part: 'tests', outcome: 'pending', score: null, reason: params[1], t: ++clock });
122
+ return { rows: [] };
123
+ }
124
+ throw new Error(`unexpected SQL: ${sql.slice(0, 80)}`);
125
+ },
126
+ };
127
+ db.add = (versionId, part, outcome, score = null) => {
128
+ const row = { id: signals.length + 1, version_id: versionId, part, outcome, score, t: ++clock };
129
+ signals.push(row); return row;
130
+ };
131
+ return db;
132
+ }
133
+
134
+ test('recompose reads the NEWEST signal per part, so a re-run moves the score', async () => {
135
+ const db = fakeDb();
136
+ db.add(7, 'security', 'passed');
137
+ db.add(7, 'tests', 'scored', 40);
138
+ const first = await recomposeScore(7, { db });
139
+ assert.equal(first.written, true);
140
+ assert.equal(first.score.overall, 40);
141
+ db.add(7, 'tests', 'scored', 90);
142
+ const second = await recomposeScore(7, { db });
143
+ assert.equal(second.score.overall, 90);
144
+ assert.equal(db.scores.length, 2, 'append-only: the history keeps the 40');
145
+ });
146
+
147
+ test('a recompose that would repeat the current score writes nothing', async () => {
148
+ const db = fakeDb();
149
+ db.add(7, 'tests', 'scored', 70);
150
+ await recomposeScore(7, { db });
151
+ const again = await recomposeScore(7, { db });
152
+ assert.equal(again.written, false);
153
+ assert.equal(db.scores.length, 1);
154
+ });
155
+
156
+ test('assessVersion on publish: Tests pending, the Security gate, then the score — and the version is New', async () => {
157
+ const db = fakeDb();
158
+ const seen = [];
159
+ const recordSecurity = async (id, { db: d }) => { seen.push(id); const row = d.add(id, 'security', 'passed'); return { ok: true, signal: row }; };
160
+ const res = await assessVersion(7, { db, recordSecurity });
161
+ assert.equal(res.ok, true);
162
+ assert.deepEqual(seen, [7]);
163
+ const tests = db.signals.find((s) => s.part === 'tests');
164
+ assert.equal(tests.outcome, 'pending', 'a published module\'s tests never run on the control plane');
165
+ assert.match(tests.reason, /separate test environment/);
166
+ assert.equal(res.score.security_passed, true);
167
+ assert.equal(res.score.overall, null);
168
+ assert.equal(res.score.is_new, true);
169
+ });
170
+
171
+ test('re-assessing never hides a real Tests score behind a newer "pending"', async () => {
172
+ const db = fakeDb();
173
+ db.add(7, 'tests', 'scored', 85);
174
+ const res = await assessVersion(7, { db, recordSecurity: async (id, { db: d }) => ({ ok: true, signal: d.add(id, 'security', 'passed') }) });
175
+ assert.equal(db.signals.filter((s) => s.part === 'tests').length, 1);
176
+ assert.equal(res.score.overall, 85);
177
+ });
178
+
179
+ test('assessVersion on an unknown version writes nothing', async () => {
180
+ const db = fakeDb({ versions: [] });
181
+ const res = await assessVersion(99, { db, recordSecurity: async () => { throw new Error('must not run'); } });
182
+ assert.equal(res.ok, false);
183
+ assert.equal(res.code, 'version_not_found');
184
+ assert.equal(db.signals.length + db.scores.length, 0);
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // the queue — one assessment at a time, capped
189
+ // ---------------------------------------------------------------------------
190
+
191
+ const quietLog = () => { const lines = []; return { lines, warn: (o, m) => lines.push(['warn', m]), error: (o, m) => lines.push(['error', m, o.err]) }; };
192
+ const gate = () => { let open; const p = new Promise((r) => { open = r; }); return { p, open }; };
193
+
194
+ test('the queue runs one assessment at a time, in order', async () => {
195
+ const log = quietLog();
196
+ const order = [];
197
+ const g = gate();
198
+ const run = async (id) => { order.push(`start ${id}`); if (id === 1) await g.p; order.push(`end ${id}`); };
199
+ queueAssessment(1, { log, run });
200
+ queueAssessment(2, { log, run });
201
+ await new Promise((r) => setImmediate(r));
202
+ assert.deepEqual(order, ['start 1'], 'the second waits while the first runs');
203
+ g.open();
204
+ await new Promise((r) => setTimeout(r, 10));
205
+ assert.deepEqual(order, ['start 1', 'end 1', 'start 2', 'end 2']);
206
+ });
207
+
208
+ test('a failed assessment is logged and never stops the next one', async () => {
209
+ const log = quietLog();
210
+ const ran = [];
211
+ queueAssessment(5, { log, run: async () => { throw new Error('npm unreachable'); } });
212
+ queueAssessment(6, { log, run: async (id) => { ran.push(id); } });
213
+ await new Promise((r) => setTimeout(r, 10));
214
+ assert.deepEqual(ran, [6]);
215
+ assert.deepEqual(log.lines, [['error', 'assessment after publish failed', 'npm unreachable']]);
216
+ });
217
+
218
+ test(`past ${QUEUE_MAX} waiting, a publish is logged as not assessed instead of piling up`, async () => {
219
+ const log = quietLog();
220
+ const g = gate();
221
+ const accepted = [];
222
+ for (let i = 0; i < QUEUE_MAX + 3; i += 1) accepted.push(queueAssessment(100 + i, { log, run: () => g.p }));
223
+ assert.equal(accepted.filter(Boolean).length, QUEUE_MAX);
224
+ assert.equal(log.lines.filter(([lvl]) => lvl === 'warn').length, 3);
225
+ assert.match(log.lines[0][1], /queue full .*module-assess-version\.js/);
226
+ g.open();
227
+ await new Promise((r) => setTimeout(r, 10));
228
+ assert.equal(queueAssessment(200, { log, run: async () => {} }), true, 'room again once it drains');
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // the publish route triggers it
233
+ // ---------------------------------------------------------------------------
234
+
235
+ test('publishing a version re-assesses that version, after the author has their answer', async () => {
236
+ const STORE = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-score-store-'));
237
+ process.env.MODULE_STORE_DIR = STORE;
238
+ const state = { modules: {}, versions: [] };
239
+ const client = {
240
+ async query(sql, params = []) {
241
+ if (/^SELECT module_key, author_id, status FROM store_modules/.test(sql)) { const m = state.modules[params[0]]; return { rows: m ? [m] : [] }; }
242
+ if (/^INSERT INTO store_modules/.test(sql)) { state.modules[params[0]] = { module_key: params[0], author_id: params[3], status: 'listed' }; return { rows: [] }; }
243
+ if (/^SELECT version FROM store_module_versions/.test(sql)) return { rows: [] };
244
+ if (/^\s*INSERT INTO store_module_versions/.test(sql)) { const row = { id: 41, module_key: params[0], version: params[1] }; state.versions.push(row); return { rows: [row] }; }
245
+ return { rows: [] };
246
+ },
247
+ release() {},
248
+ };
249
+ const poolPath = require.resolve('../src/bongos/pool.js');
250
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => client, query: client.query } } };
251
+ const score = require('../scripts/gds/module-assess-version.js');
252
+ const real = score.queueAssessment;
253
+ const calls = [];
254
+ score.queueAssessment = (id) => { calls.push(id); return true; };
255
+ try {
256
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
257
+ const artifact = require('../scripts/gds/module-artifact.js');
258
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-assess-score-src-'));
259
+ fs.mkdirSync(path.join(src, 'weather'));
260
+ fs.writeFileSync(path.join(src, 'weather', 'module.json'), JSON.stringify({ key: 'weather', title: 'Weather', description: 'd', version: '1.0.0', coreVersion: '^1.0.0', contributes: {} }));
261
+ fs.writeFileSync(path.join(src, 'weather', 'HOWTO.md'), artifact.HOWTO_SECTIONS.map((h) => `## ${h}\nSome text.\n`).join('\n'));
262
+ const { tgz } = artifact.packModule('weather', { modulesDir: src, modeOf: () => '644' });
263
+
264
+ const router = buildModulesRouter();
265
+ const layer = router.stack.find((l) => l.route && l.route.path === '/store/modules/:key/versions' && l.route.methods.post);
266
+ const handle = layer.route.stack[layer.route.stack.length - 1].handle;
267
+ const res = { statusCode: 200, body: null, json(b) { this.body = b; return this; }, status(c) { this.statusCode = c; return this; }, fail(code, o) { this.statusCode = o.status; this.body = { error: code }; return this; } };
268
+ await handle({ params: { key: 'weather' }, body: tgz, builder: { id: 42 } }, res, (e) => { if (e) throw e; });
269
+ assert.equal(res.statusCode, 201, JSON.stringify(res.body));
270
+ assert.deepEqual(calls, [], 'the author is answered first — the gate can take a minute');
271
+ await new Promise((r) => setImmediate(r));
272
+ assert.deepEqual(calls, [41], 'then the new version is queued for assessment');
273
+ } finally {
274
+ score.queueAssessment = real;
275
+ }
276
+ });