@bongos/core 1.20.69 → 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.
@@ -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 };
@@ -58,7 +58,7 @@
58
58
  // bongos module new <key> scaffold modules/<key>/
59
59
  // bongos module new <key> --force overwrite an existing modules/<key>/
60
60
  // bongos module list [--catalog] browse the module catalog (author + origin credit)
61
- // bongos upgrade pre-check enabled modules vs the core version
61
+ // bongos upgrade [--no-store] pre-check enabled modules vs the core version + list store updates
62
62
  // bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
63
63
  // bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
64
64
  // bongos module update <key> [--version X.Y.Z] move an installed store module forward
@@ -371,10 +371,10 @@ function gatherUpgrade({ modulesDir = MODULES_DIR, core = coreVersion(), env = p
371
371
 
372
372
  const rows = [];
373
373
  for (const { key, manifest } of loaded) {
374
- rows.push({ key, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true, ...upkeep(manifest) });
374
+ rows.push({ key, version: manifest.version || null, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true, ...upkeep(manifest) });
375
375
  }
376
376
  for (const { key, manifest, reason } of skippedFull) {
377
- rows.push({ key, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason, ...upkeep(manifest) });
377
+ rows.push({ key, version: (manifest && manifest.version) || null, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason, ...upkeep(manifest) });
378
378
  }
379
379
  rows.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
380
380
  return { rows, errors, core };
@@ -663,7 +663,76 @@ function cmdNew(args, { log = console.log, errlog = console.error } = {}) {
663
663
  return 0;
664
664
  }
665
665
 
666
- function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather = gatherUpgrade } = {}) {
666
+ // ---------------------------------------------------------------------------
667
+ // Available module updates — the store half of `bongos upgrade` (task 1003787)
668
+ // ---------------------------------------------------------------------------
669
+
670
+ // Ask the store for the newest version of every module here that carries an exact
671
+ // version (the I/O half). A key the store does not know is absent from the answer,
672
+ // so a module built here, or one the core ships, simply has nothing to report.
673
+ // Never throws: no session or an unreachable store comes back as
674
+ // { checked: false, reason }, because the compatibility pre-check must work
675
+ // offline and must never fail on the store's account.
676
+ async function gatherUpdates(rows, { request } = {}) {
677
+ const keys = rows.filter((r) => r.version && parseVersion(String(r.version))).map((r) => r.key);
678
+ if (!keys.length) return { checked: true, latest: {} };
679
+ let call;
680
+ try { call = request || storeRequest(); }
681
+ catch (e) { return { checked: false, reason: e.message }; }
682
+ let res;
683
+ try { res = await call('GET', `/store/modules/latest?keys=${keys.map(encodeURIComponent).join(',')}`); }
684
+ catch (e) { return { checked: false, reason: `the store could not be reached — ${e.message}` }; }
685
+ // A 404 here is a server whose core predates this route, not a missing module.
686
+ if (res.status === 404) return { checked: false, reason: "this server's store does not answer update checks yet (its core is older)" };
687
+ if (!res.ok) return { checked: false, reason: `the store answered ${storeError(res)}` };
688
+ return { checked: true, latest: (res.data && res.data.latest) || {} };
689
+ }
690
+
691
+ // Decide what is stale from rows + the store's answer (pure — tests drive it).
692
+ // UPDATES = a module whose store version is newer than the one installed; `fits`
693
+ // says whether that newer version can mount on this core (when it cannot, the
694
+ // update waits for the core — it is not something to run now). DELISTED = an
695
+ // installed module the store has delisted: it keeps working, but no further
696
+ // versions will come (ADR 0338 D2). Neither ever blocks — an update is news, and
697
+ // what blocks `bongos upgrade` is still only an incompatible ENABLED module.
698
+ function evaluateUpdates(rows = [], latest = {}, core) {
699
+ const { satisfies } = require('../../src/module-loader/semver');
700
+ const updates = [];
701
+ const delisted = [];
702
+ for (const r of rows) {
703
+ const store = latest[r.key];
704
+ const have = r.version && parseVersion(String(r.version));
705
+ if (!store || !have) continue;
706
+ if (store.status === 'delisted') { delisted.push({ key: r.key, installed: r.version, enabled: r.enabled }); continue; }
707
+ const next = parseVersion(String(store.version));
708
+ if (!next || cmp(next, have) <= 0) continue;
709
+ let fits = false;
710
+ try { fits = !!(core && store.core_version && satisfies(core, store.core_version)); } catch { fits = false; }
711
+ updates.push({ key: r.key, installed: r.version, latest: store.version, coreVersion: store.core_version, enabled: r.enabled, fits });
712
+ }
713
+ return { updates, delisted };
714
+ }
715
+
716
+ function reportUpdates(found, verdict, { log, errlog }) {
717
+ log('\nModule updates (from the store):');
718
+ if (!found.checked) {
719
+ log(` not checked — ${found.reason}. The compatibility check above still stands.`);
720
+ return;
721
+ }
722
+ const { updates, delisted } = evaluateUpdates(verdict.rows, found.latest, verdict.core);
723
+ for (const u of updates) {
724
+ const how = u.fits
725
+ ? `run \`bongos module update ${u.key}\``
726
+ : `needs core ${u.coreVersion}; this core is ${verdict.core}, so it waits until the core moves`;
727
+ log(` ↑ ${u.key.padEnd(18)} ${u.installed} → ${u.latest} — ${how}`);
728
+ }
729
+ for (const d of delisted) {
730
+ errlog(` ! ${d.key.padEnd(18)} ${d.installed} — delisted in the store: it keeps working, but no further versions will come`);
731
+ }
732
+ if (!updates.length && !delisted.length) log(' every installed store module is on its newest version.');
733
+ }
734
+
735
+ async function cmdUpgrade(args = [], { log = console.log, errlog = console.error, gather = gatherUpgrade, checkUpdates = gatherUpdates } = {}) {
667
736
  const data = gather();
668
737
  const verdict = evaluateUpgrade(data);
669
738
 
@@ -701,6 +770,11 @@ function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather =
701
770
  errlog(` ! ${verdict.undeclared.length} module(s) declare no \`maintenance\` block — nobody is on the hook for them as the core advances (ADR 0166): ${verdict.undeclared.map((r) => r.key).join(', ')}`);
702
771
  }
703
772
 
773
+ // What is stale here, beside what is broken: the store's newer versions of the
774
+ // modules installed from it. Reported either way, and it never changes the exit
775
+ // code. `--no-store` skips the read (offline, or a CI pre-check).
776
+ if (!args.includes('--no-store')) reportUpdates(await checkUpdates(verdict.rows), verdict, { log, errlog });
777
+
704
778
  if (!verdict.ok) {
705
779
  errlog(`\nREFUSING: ${verdict.blocking.length} enabled module(s) incompatible with core ${verdict.core}: ${verdict.blocking.map((b) => b.key).join(', ')}.`);
706
780
  errlog(`Widen each module's coreVersion to a range that includes ${verdict.core} (or hold the core upgrade) before proceeding.`);
@@ -1200,7 +1274,8 @@ function printHelp(out = console.log) {
1200
1274
  out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
1201
1275
  out(' bongos module list [--catalog] [--search <text>] [--json]');
1202
1276
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1203
- out(' bongos upgrade Pre-check enabled modules against the core version (fail-closed)');
1277
+ out(' bongos upgrade [--no-store] Pre-check enabled modules against the core version (fail-closed),');
1278
+ out(' then list newer store versions of installed modules (never blocks)');
1204
1279
  out(' bongos module check <key> [--sign-off "Name <email>"]');
1205
1280
  out(' AGPL/publish pre-check before upstreaming + record a DCO-style sign-off');
1206
1281
  out(' bongos module submit <key> [--sign-off "Name <email>"]');
@@ -1236,7 +1311,7 @@ async function main(argv) {
1236
1311
  module.exports = {
1237
1312
  main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
1238
1313
  gatherCatalog, formatCatalog, ownProjectName,
1239
- scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
1314
+ scaffoldModule, gatherUpgrade, evaluateUpgrade, gatherUpdates, evaluateUpdates, buildManifest,
1240
1315
  formatMaintenanceHint, isPastRemoveAfter,
1241
1316
  checkModulePublishability, recordSignOff, readSignOff, DCO_STATEMENT,
1242
1317
  listModuleFiles, packageModuleForSubmission,
@@ -1245,8 +1320,11 @@ module.exports = {
1245
1320
  };
1246
1321
 
1247
1322
  if (require.main === module) {
1323
+ // Set the exit code and drain, never a hard exit: install, update and upgrade end
1324
+ // after a store request, and a hard exit there aborts natively on Windows + Node 24
1325
+ // (task 1002734).
1248
1326
  Promise.resolve(main(process.argv)).then(
1249
- (code) => process.exit(code),
1327
+ (code) => { process.exitCode = code; },
1250
1328
  (e) => { console.error(e); process.exit(1); }
1251
1329
  );
1252
1330
  }
@@ -1495,5 +1495,5 @@ module.exports = {
1495
1495
  };
1496
1496
 
1497
1497
  if (require.main === module) {
1498
- main().then((code) => process.exit(code)).catch((e) => { process.stderr.write(`✖ ${e.message}\n`); process.exit(1); });
1498
+ main().then((code) => { if (arg('--to', process.argv.slice(2))) process.exit(code); else process.exitCode = code; }).catch((e) => { process.stderr.write(`✖ ${e.message}\n`); process.exit(1); }); // bare pre-check drains: it ends after a store read (task 1003787), and a hard exit there aborts on Windows (task 1002734)
1499
1499
  }
@@ -208,6 +208,30 @@ async function publishedVersionSet(keys, { db } = {}) {
208
208
  return new Set(rows.map((r) => `${r.module_key}@${r.version}`));
209
209
  }
210
210
 
211
+ // The newest published version of each of these modules, for `bongos upgrade`'s
212
+ // "updates available" report (task 1003787). Read-only: unlike resolveAcquirable it
213
+ // takes no entitlement and refuses nothing, so a delisted module still answers,
214
+ // with its status, and the caller says it gets no further versions (ADR 0338 D2).
215
+ // A key the store does not know is simply absent. One query for the whole list.
216
+ // Returns { [key]: { version, core_version, status } }.
217
+ async function latestVersions(keys, { db } = {}) {
218
+ if (!keys.length) return {};
219
+ const pool = db || require('./pool').pool;
220
+ const { rows } = await pool.query(
221
+ `SELECT v.module_key, v.version, v.core_version, m.status
222
+ FROM store_module_versions v JOIN store_modules m ON m.module_key = v.module_key
223
+ WHERE v.module_key = ANY($1)`, [keys]);
224
+ const out = {};
225
+ for (const r of rows) {
226
+ const next = parseVersion(r.version);
227
+ if (!next) continue;
228
+ const have = out[r.module_key];
229
+ if (have && cmp(parseVersion(have.version), next) >= 0) continue;
230
+ out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status };
231
+ }
232
+ return out;
233
+ }
234
+
211
235
  // The tarball on disk for a version row. artifact_path is relative to the store dir
212
236
  // (relativeArtifactPath), and artifactPath() re-validates key + version, so a row
213
237
  // can never point a read outside the store.
@@ -221,5 +245,5 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
221
245
 
222
246
  module.exports = {
223
247
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
224
- isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
248
+ isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
225
249
  };
@@ -78,10 +78,11 @@ 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,
84
- resolveAcquirable, resolveReadable, publishedVersionSet, versionArtifactFile,
85
+ resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
85
86
  } = require('../module-store');
86
87
  const { KEY_RE } = require('../../module-loader/manifest-schema');
87
88
  const { parseVersion } = require('../../module-loader/semver');
@@ -154,6 +155,11 @@ function listModules() {
154
155
  // the oldest entry is dropped.
155
156
  const HOWTO_CACHE_MAX = 200;
156
157
 
158
+ // How many module keys one GET /store/modules/latest may ask about (task 1003787).
159
+ // An instance installs a handful; the cap keeps the query string and the ANY()
160
+ // list bounded.
161
+ const STORE_LATEST_MAX_KEYS = 200;
162
+
157
163
  module.exports = function buildModulesRouter() {
158
164
  const router = express.Router();
159
165
  // Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
@@ -388,6 +394,12 @@ module.exports = function buildModulesRouter() {
388
394
 
389
395
  console.log(`[gds] module "${key}" ${v.version} published to the store by builder ${req.builder.id}`);
390
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 }));
391
403
  }));
392
404
 
393
405
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
@@ -565,6 +577,26 @@ module.exports = function buildModulesRouter() {
565
577
  });
566
578
  }));
567
579
 
580
+ // The newest published version of each named module — what `bongos upgrade`
581
+ // compares an instance's installed store modules against to report the updates
582
+ // available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS.
583
+ //
584
+ // rank: any-builder — a read of public store metadata (version numbers and the
585
+ // core range each needs, the same facts the how-to page shows). It takes no
586
+ // entitlement and changes nothing, unlike acquire; a key the store does not know
587
+ // is simply absent from the answer.
588
+ router.get('/store/modules/latest', auth.requireBuilder,
589
+ asyncHandler('GET /store/modules/latest', async (req, res) => {
590
+ const raw = typeof req.query.keys === 'string' ? req.query.keys : '';
591
+ const keys = [...new Set(raw.split(',').map((k) => k.trim()).filter(Boolean))];
592
+ if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
593
+ return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
594
+ }
595
+ const latest = await latestVersions(keys);
596
+ res.set('Cache-Control', 'no-store');
597
+ res.json({ latest });
598
+ }));
599
+
568
600
  router.post('/store/modules/:key/acquired', auth.requireBuilder,
569
601
  asyncHandler('POST /store/modules/:key/acquired', async (req, res) => {
570
602
  const { key } = req.params;
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.69'; // 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
+ });