openvibers/openvibe.shared Commits

The floating-promise check (plan T0): scripts/floating-promises.js parses a repository with acorn and lists every call statement inside a function whose promise nobody awaits, returns, assigns, void-s or chains: a bare call to an async function of the same file or this.f() of an async method (rule a), or a known async API (rule b: .tx(, .transaction(async, db/tx run/get/all/exec/query only where the repository has no better-sqlite3, pool/client.query, .enqueue(, fetch(, fs.promises, .close() on db/pool/client/store). '// floating-ok: <reason>' opts one out (the reason is required). Shared's CI fails on a finding; the shared reusable workflow lists them as warnings until every repository is clean, then fails too. Tests cover each rule, each exemption and the SQLite case. npm test 43/43.

OP OpenVibers committed 29 Sept 2026 · ba460a289d · 5 files, +380 −0


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex f79fea5..d4ad41d 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -17,6 +17,10 @@ jobs:
- run: npm test
- name: pin drift (openvibe-* library pins)
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
++ - name: install acorn (floating promises check)
++ run: npm i --no-save acorn@8 acorn-walk@8
++ - name: floating promises (unawaited async calls)
++ run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/floating-promises.js | node - "$GITHUB_WORKSPACE"
- name: size report
run: node scripts/size-report.js
# The release artifact is GitHub's tarball of the tag (repository root = package root):
ddiff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
iindex 33050c5..f7bbac3 100644
---- a/.github/workflows/test.yml
++++ b/.github/workflows/test.yml
@@@ -25,6 +25,9 @@ name: test
# pin drift scripts/pin-drift.js from OpenVibe.Shared main over the whole repository: a pin of
# openvibe-contracts/-shared/-sdk/-publishing that is not a published tag, or more than one
# minor behind the latest release, fails (roadmap WS-C task 5; `pin-drift: false` skips)
++# floating scripts/floating-promises.js from OpenVibe.Shared main over the whole repository: an
++# async call whose result nobody awaits is listed as a warning for now, and fails the build once
++# every repository is clean (acorn installed first; plan T0)
# extra `extra`, when set: any further commands, one per line (e.g. a budget check), with a
# read-only GITHUB_TOKEN
on:
@@@ -139,6 +142,12 @@ jobs:
if: ${{ inputs.pin-drift }}
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
++ - name: install acorn (floating promises check)
++ working-directory: .
++ run: npm i --no-save acorn@8 acorn-walk@8
++ - name: floating promises (report-only until every repository is clean; plan T0)
++ working-directory: .
++ run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/floating-promises.js | node - "$GITHUB_WORKSPACE" || echo "::warning title=floating promises::unawaited async calls found (see the list above); this step fails the build once the estate is clean"
- name: extra
if: ${{ inputs.extra != '' }}
env:
ddiff --git a/README.md b/README.md
iindex 72b598d..aa8efbc 100644
---- a/README.md
++++ b/README.md
@@@ -23,6 +23,7 @@ were reconciled first.
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
++| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
Every module file has an explicit `exports` entry (no wildcards), so `require('openvibe-shared/navbar')`,
`require.resolve('openvibe-shared/package.json')` and the rest all resolve. It stays one package with
ddiff --git a/scripts/floating-promises.js b/scripts/floating-promises.js
nnew file mode 100644
iindex 0000000..61cb87e
---- /dev/null
++++ b/scripts/floating-promises.js
@@@ -0,0 +1,212 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Floating promises (plan T0, the PostgreSQL move's main bug class): an async call whose result
++ * nobody awaits, so its failure is an unhandled rejection and a transaction commits without it.
++ *
++ * node scripts/floating-promises.js <repo dir> [--json]
++ * curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/floating-promises.js \
++ * | node - "$GITHUB_WORKSPACE" # CI installs acorn first: npm i --no-save acorn@8 acorn-walk@8
++ *
++ * Parses every .js/.mjs/.cjs under server/, apps/<app>/server/, scripts/, lib/ and src/ (node_modules,
++ * dist, public/, vendor/ and test fixtures are skipped) with acorn + acorn-walk and reports an
++ * ExpressionStatement whose expression is a call that is not awaited, returned, assigned, `void`-ed or
++ * chained with .then(/.catch(/.finally(, when the callee is
++ *
++ * a) a function declared async in the same file (declarations, `const f = async …`, class/object
++ * methods, `module.exports.f = async …`), called by that name or as this.f(/obj.f(; or
++ * b) a known async API: <anything>.tx(, .transaction( with an async callback, db.run/get/all/exec/query,
++ * tx.run/get/all/exec, .enqueue(, pool.query(, client.query(, fetch(, .close() on db/pool/client/
++ * server/store, fs.promises.*, fsp.*.
++ *
++ * Only statements inside a function count; a top-level fire-and-forget in a script may exit with the
++ * process, so those are reported only in server code. An opt-out is a comment on the same line or the
++ * line above — `// floating-ok: <reason>` — and a reason is required.
++ *
++ * Exit 0 = none, 1 = at least one floating promise.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const ROOTS = ['server', 'scripts', 'lib', 'src'];
++const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', 'public', 'vendor', '.git', 'fixtures', '__fixtures__', 'test', 'tests', '__tests__', 'testdata', 'spec']);
++const EXTS = new Set(['.js', '.mjs', '.cjs']);
++const OPT_OUT = /\/\/\s*floating-ok:\s*\S/;
++const DB_METHODS = new Set(['run', 'get', 'all', 'exec', 'query']);
++const CLOSE_OBJECTS = new Set(['db', 'pool', 'client', 'store']);
++
++/** Require a parser from the current working directory or from this script's own tree. */
++function load(name) {
++ const { createRequire } = require('module');
++ const tried = [];
++ try { return require(name); } catch (err) { tried.push(err.message); }
++ for (const base of [process.cwd(), __dirname]) {
++ try { return createRequire(path.join(base, '__floating__.js'))(name); } catch (err) { tried.push(err.message); }
++ }
++ throw new Error(`cannot load ${name} (install it: npm i --no-save acorn@8 acorn-walk@8): ${tried.join('; ')}`);
++}
++
++const isAsyncFn = (n) => !!n && (n.type === 'ArrowFunctionExpression' || n.type === 'FunctionExpression' || n.type === 'FunctionDeclaration') && n.async === true;
++
++/** The property name of a MemberExpression (identifier or string literal), else null. */
++function memberProp(node) {
++ if (!node || node.type !== 'MemberExpression') return null;
++ if (!node.computed && node.property.type === 'Identifier') return node.property.name;
++ if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string') return node.property.value;
++ return null;
++}
++
++function keyName(key, computed) {
++ if (!key) return null;
++ if (!computed && key.type === 'Identifier') return key.name;
++ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
++ return null;
++}
++
++/** Every name bound to an async function anywhere in this file. */
++function asyncNames(ast, walk) {
++ const names = new Set();
++ walk.simple(ast, {
++ FunctionDeclaration(n) { if (n.async && n.id) names.add(n.id.name); },
++ VariableDeclarator(n) { if (n.id.type === 'Identifier' && isAsyncFn(n.init)) names.add(n.id.name); },
++ AssignmentExpression(n) { if (isAsyncFn(n.right)) { const p = memberProp(n.left); if (p) names.add(p); } },
++ Property(n) { if (isAsyncFn(n.value)) { const k = keyName(n.key, n.computed); if (k) names.add(k); } },
++ MethodDefinition(n) { if (n.value && n.value.async) { const k = keyName(n.key, n.computed); if (k) names.add(k); } },
++ PropertyDefinition(n) { if (isAsyncFn(n.value)) { const k = keyName(n.key, n.computed); if (k) names.add(k); } },
++ });
++ return names;
++}
++
++// A bare call to an async function of this file, or this.f() where f is an async method of this file. (obj.f() is not
++// matched: without types a method name shared with some async function elsewhere in the file, console.log beside a
++// local `const log = async`, is noise.)
++function ruleA(callee, asyncNamesSet) {
++ if (callee.type === 'Identifier') return asyncNamesSet.has(callee.name);
++ if (callee.type === 'MemberExpression' && callee.object.type === 'ThisExpression') { const p = memberProp(callee); return !!p && asyncNamesSet.has(p); }
++ return false;
++}
++
++// asyncDb: the repository's db handle is asynchronous (no better-sqlite3 dependency): only then are db.run/get/all/exec
++// promises. A better-sqlite3 service's db calls are synchronous and never float.
++function ruleB(callee, args, { asyncDb = true } = {}) {
++ if (callee.type === 'Identifier') return callee.name === 'fetch';
++ if (callee.type !== 'MemberExpression') return false;
++ const obj = callee.object;
++ const objName = obj.type === 'Identifier' ? obj.name : null;
++ const prop = memberProp(callee);
++ if (objName === 'fsp') return true; // fsp.*
++ if (obj.type === 'MemberExpression' && obj.object.type === 'Identifier' && obj.object.name === 'fs' && memberProp(obj) === 'promises') return true; // fs.promises.*
++ if (prop === 'tx') return true; // <anything>.tx(
++ if (prop === 'transaction' && args.some(isAsyncFn)) return true; // .transaction(async …)
++ if (asyncDb && (objName === 'db' || objName === 'tx') && DB_METHODS.has(prop)) return true; // db.run/get/all/exec/query, tx.run/get/all/exec
++ if (objName === 'pool' && prop === 'query') return true;
++ if (objName === 'client' && prop === 'query') return true;
++ if (prop === 'enqueue') return true;
++ if (prop === 'close' && CLOSE_OBJECTS.has(objName)) return true; // .close() on db/pool/client/server/store
++ return false;
++}
++
++const isFn = (n) => n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression';
++
++function parse(source, ext, acorn) {
++ const opts = { ecmaVersion: 'latest', allowHashBang: true, allowReturnOutsideFunction: true, locations: true };
++ const order = ext === '.mjs' ? ['module', 'script'] : ['script', 'module'];
++ let last;
++ for (const sourceType of order) {
++ try { return acorn.parse(source, { ...opts, sourceType }); } catch (err) { last = err; }
++ }
++ throw last;
++}
++
++function walkTree(dir) {
++ const files = [];
++ const visit = (abs) => {
++ let entries;
++ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
++ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
++ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) visit(path.join(abs, e.name)); }
++ else if (e.isFile() && EXTS.has(path.extname(e.name))) files.push(path.join(abs, e.name));
++ }
++ };
++ for (const root of ROOTS) { const abs = path.join(dir, root); if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) visit(abs); }
++ const apps = path.join(dir, 'apps');
++ if (fs.existsSync(apps)) {
++ for (const app of fs.readdirSync(apps).sort()) {
++ const abs = path.join(apps, app, 'server');
++ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) visit(abs);
++ }
++ }
++ return files;
++}
++
++const isServerPath = (rel) => { const s = rel.split(path.sep); return s[0] === 'server' || (s[0] === 'apps' && s[2] === 'server'); };
++
++/** Scan a repository directory. → { findings, allowed, files, errors } */
++/** True when the repository's package.json (or an apps/<app>/package.json) depends on better-sqlite3. */
++function usesSyncSqlite(dir) {
++ const has = (p) => { try { const j = JSON.parse(fs.readFileSync(p, 'utf8')); return Boolean((j.dependencies || {})['better-sqlite3']); } catch { return false; } };
++ if (has(path.join(dir, 'package.json'))) return true;
++ try { return fs.readdirSync(path.join(dir, 'apps')).some((a) => has(path.join(dir, 'apps', a, 'package.json'))); } catch { return false; }
++}
++
++function scan(dir = '.') {
++ const asyncDb = !usesSyncSqlite(dir);
++ const acorn = load('acorn');
++ const walk = load('acorn-walk');
++ const findings = [];
++ const errors = [];
++ let allowed = 0;
++ const files = walkTree(dir);
++ for (const file of files) {
++ const rel = path.relative(dir, file) || file;
++ let source;
++ try { source = fs.readFileSync(file, 'utf8'); } catch (err) { errors.push({ file: rel, error: err.message }); continue; }
++ let ast;
++ try { ast = parse(source, path.extname(file), acorn); } catch (err) { errors.push({ file: rel, error: err.message }); continue; }
++ const lines = source.split('\n');
++ const names = asyncNames(ast, walk);
++ const server = isServerPath(rel);
++ walk.fullAncestor(ast, (node, state, ancestors) => {
++ if (node.type !== 'ExpressionStatement') return;
++ let expr = node.expression;
++ if (expr.type === 'ChainExpression') expr = expr.expression;
++ if (expr.type !== 'CallExpression') return;
++ const callee = expr.callee;
++ const prop = memberProp(callee);
++ if (prop === 'then' || prop === 'catch' || prop === 'finally') return; // rejection is handled
++ let rule = null;
++ if (ruleA(callee, names)) rule = 'a';
++ else if (ruleB(callee, expr.arguments, { asyncDb })) rule = 'b';
++ if (!rule) return;
++ const inside = ancestors.some((a) => a !== node && isFn(a));
++ if (!inside && !server) return; // top-level script: allowed to exit with the process
++ const line = node.loc.start.line;
++ if (OPT_OUT.test(lines[line - 1] || '') || OPT_OUT.test(lines[line - 2] || '')) { allowed++; return; }
++ findings.push({ file: rel, line, column: node.loc.start.column + 1, callee: source.slice(callee.start, callee.end), rule });
++ }, walk.base);
++ }
++ return { findings, allowed, files: files.length, errors };
++}
++
++function main(dir = '.', { json = false } = {}) {
++ const { findings, allowed, errors } = scan(dir);
++ if (json) {
++ console.log(JSON.stringify(findings, null, 2));
++ } else {
++ for (const f of findings) console.log(`${f.file}:${f.line} ${f.callee} (rule ${f.rule})`);
++ console.log(`floating promises: ${findings.length} found, ${allowed} allowed`);
++ for (const e of errors) console.error(`floating-promises: could not parse ${e.file}: ${e.error}`);
++ }
++ return findings.length ? 1 : 0;
++}
++
++module.exports = { scan, main, walkTree, asyncNames, ruleA, ruleB, memberProp, isServerPath, OPT_OUT };
++
++if (require.main === module || !module.parent) {
++ const argv = process.argv.slice(2);
++ const json = argv.includes('--json');
++ const dir = argv.find((a) => !a.startsWith('-')) || '.';
++ let code;
++ try { code = main(dir, { json }); } catch (err) { console.error(`floating-promises: ${err.message}`); process.exit(2); }
++ process.exit(code);
++}
ddiff --git a/test/floating-promises.test.js b/test/floating-promises.test.js
nnew file mode 100644
iindex 0000000..0ae0708
---- /dev/null
++++ b/test/floating-promises.test.js
@@@ -0,0 +1,154 @@
++'use strict';
++// scripts/floating-promises.js (plan T0): each rule fires, each exemption holds, and the opt-out needs
++// a reason. Fixtures are tiny source strings written to a temp repository tree.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++// The check itself is run by its own CI step, which installs acorn first; `npm test` here may run
++// before that, so this file skips (○, not green) when the parsers are absent.
++try { require.resolve('acorn'); require.resolve('acorn-walk'); } catch {
++ console.log('floating-promises: skipped (acorn not installed; the check step installs it with npm i --no-save acorn@8 acorn-walk@8)');
++ process.exit(0);
++}
++const { scan, main, OPT_OUT } = require('../scripts/floating-promises');
++
++let tmp = [];
++function fixture(files) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'floating-'));
++ tmp.push(dir);
++ for (const [rel, src] of Object.entries(files)) {
++ const p = path.join(dir, rel);
++ fs.mkdirSync(path.dirname(p), { recursive: true });
++ fs.writeFileSync(p, src);
++ }
++ return dir;
++}
++const lines = (res) => res.findings.map((f) => `${f.file}:${f.line}`);
++const callees = (res) => res.findings.map((f) => f.callee).sort();
++
++try {
++ // Rule a: async declared in the same file — by name and this.f(; obj.f( is NOT matched (a shared method name is noise
++ // without types). Exemptions hold.
++ {
++ const dir = fixture({ 'server/a.js': [
++ 'async function save() { return 1; }',
++ 'module.exports.push = async function () {};',
++ 'async function handler() {',
++ ' save(); // rule a',
++ ' this.save(); // rule a',
++ ' obj.save(); // not matched: obj is not this',
++ ' module.exports.push(); // not matched: a member of another object',
++ ' await save();',
++ ' void save();',
++ ' return save();',
++ ' const p = save();',
++ ' save().catch(() => {});',
++ ' save().then(() => {});',
++ ' save().finally(() => {});',
++ '}',
++ ].join('\n') + '\n' });
++ const res = scan(dir);
++ assert.deepStrictEqual(lines(res), ['server/a.js:4', 'server/a.js:5'], 'awaited/returned/void-ed/assigned/chained calls are exempt');
++ assert.ok(res.findings.every((f) => f.rule === 'a'), 'all rule a');
++ assert.deepStrictEqual(callees(res), ['save', 'this.save']);
++ }
++
++ // A better-sqlite3 service: its db calls are synchronous, so db.run() never floats there (fetch still does).
++ {
++ const src = ['async function handler() {', ' db.run("x");', ' fetch("u");', '}'].join('\n') + '\n';
++ const sync = fixture({ 'package.json': JSON.stringify({ dependencies: { 'better-sqlite3': '^11' } }), 'server/s.js': src });
++ assert.deepStrictEqual(callees(scan(sync)), ['fetch'], 'db.run is synchronous with better-sqlite3');
++ const pg = fixture({ 'package.json': JSON.stringify({ dependencies: { pg: '^8' } }), 'server/s.js': src });
++ assert.deepStrictEqual(callees(scan(pg)), ['db.run', 'fetch'], 'db.run is a promise without better-sqlite3');
++ }
++
++ // Rule b: every known async API.
++ {
++ const dir = fixture({ 'apps/api/server/b.js': [
++ 'async function handler() {',
++ " fetch('/x');",
++ " db.query('SELECT 1');",
++ " db.run('INSERT');",
++ " tx.exec('x');",
++ " tx.run('x');",
++ " pool.query('x');",
++ " client.query('x');",
++ ' db.close();',
++ ' pool.close();',
++ ' store.close();',
++ ' queue.enqueue(job);',
++ ' repo.tx(async (t) => { await t.run("x"); });',
++ ' knex.transaction(async (trx) => { await trx.run("x"); });',
++ " fs.promises.readFile('x');",
++ " fsp.writeFile('y', 'z');",
++ " fetch('/x').catch(() => {});", // exempt
++ ' knex.transaction((trx) => trx.run("x"));', // sync callback: not reported
++ '}',
++ ].join('\n') + '\n' });
++ const res = scan(dir);
++ assert.strictEqual(res.findings.length, 15, `15 rule-b findings, got ${lines(res).join(' ')}`);
++ assert.ok(res.findings.every((f) => f.rule === 'b'), 'all rule b');
++ for (const c of ['fetch', 'db.query', 'db.run', 'tx.exec', 'tx.run', 'pool.query', 'client.query', 'db.close', 'pool.close', 'store.close', 'queue.enqueue', 'repo.tx', 'knex.transaction', 'fs.promises.readFile', 'fsp.writeFile']) {
++ assert.ok(callees(res).includes(c), `${c} reported`);
++ }
++ }
++
++ // Opt-out: a reason on the same line or the line above counts; a bare comment is still reported.
++ {
++ const dir = fixture({ 'lib/a.js': [
++ 'async function f() {}',
++ 'function g() {',
++ ' f(); // floating-ok: the process may exit',
++ ' // floating-ok:',
++ ' f();',
++ ' // floating-ok: no reason above is fine too',
++ ' f();',
++ ' f(); // floating-ok',
++ '}',
++ ].join('\n') + '\n' });
++ const res = scan(dir);
++ assert.strictEqual(res.allowed, 2, 'two reasoned opt-outs');
++ assert.deepStrictEqual(lines(res), ['lib/a.js:5', 'lib/a.js:8'], 'a comment without a reason is still a finding');
++ assert.ok(OPT_OUT.test('// floating-ok: because'), 'reason required: present');
++ assert.ok(!OPT_OUT.test('// floating-ok:'), 'reason required: absent');
++ assert.ok(!OPT_OUT.test('// floating-ok'), 'reason required: no colon');
++ }
++
++ // A sync function named like an async one in another file is not reported.
++ {
++ const dir = fixture({
++ 'server/def.js': 'async function save() {}\n',
++ 'server/use.js': 'function save() {}\nfunction h() { save(); }\n',
++ });
++ assert.strictEqual(scan(dir).findings.length, 0, 'same-file resolution only');
++ }
++
++ // Top-level: allowed in scripts (the process may exit), reported in server code.
++ {
++ const dir = fixture({
++ 'scripts/top.js': 'async function go() {}\ngo();\n',
++ 'server/top.js': 'async function go() {}\ngo();\n',
++ });
++ assert.deepStrictEqual(lines(scan(dir)), ['server/top.js:2'], 'top-level only in server code');
++ }
++
++ // main(): the summary line and exit status.
++ {
++ const dir = fixture({ 'server/x.js': 'async function f() {}\nf();\n' });
++ const logs = [];
++ const orig = console.log;
++ console.log = (...a) => logs.push(a.join(' '));
++ const code = main(dir);
++ console.log = orig;
++ assert.strictEqual(code, 1, 'exit 1 when a floating promise is found');
++ assert.match(logs.join('\n'), /server\/x\.js:2/, 'prints file:line');
++ assert.match(logs.join('\n'), /^floating promises: 1 found, 0 allowed$/m, 'summary line');
++ }
++
++ assert.strictEqual(main(fixture({ 'server/clean.js': 'async function f() {}\nasync function h() { await f(); }\n' })), 0, 'clean tree exits 0');
++ console.log('floating-promises: all checks passed');
++} finally {
++ for (const dir of tmp) fs.rmSync(dir, { recursive: true, force: true });
++}