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.
Changes
@@@ -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 reportrun: node scripts/size-report.js# The release artifact is GitHub's tarball of the tag (repository root = package root):@@@ -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_TOKENon:@@@ -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: extraif: ${{ inputs.extra != '' }}env:@@@ -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 withnnew file mode 100644@@@ -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);++}nnew file mode 100644@@@ -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 });++}
@@ -17,6 +17,10 @@ jobs:
@@ -17,6 +17,10 @@ jobs:
- run: npm test
- run: npm test
- name: pin drift (openvibe-* library pins)
- name: pin drift (openvibe-* library pins)
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
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
- name: size report
run: node scripts/size-report.js
run: node scripts/size-report.js
# The release artifact is GitHub's tarball of the tag (repository root = package root):
# The release artifact is GitHub's tarball of the tag (repository root = package root):
@@ -25,6 +25,9 @@ name: test
@@ -25,6 +25,9 @@ name: test
# pin drift scripts/pin-drift.js from OpenVibe.Shared main over the whole repository: a pin of
# 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
# 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)
# 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
# extra `extra`, when set: any further commands, one per line (e.g. a budget check), with a
# read-only GITHUB_TOKEN
# read-only GITHUB_TOKEN
on:
on:
@@ -139,6 +142,12 @@ jobs:
@@ -139,6 +142,12 @@ jobs:
if: ${{ inputs.pin-drift }}
if: ${{ inputs.pin-drift }}
working-directory: .
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
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
- name: extra
if: ${{ inputs.extra != '' }}
if: ${{ inputs.extra != '' }}
env:
env:
@@ -23,6 +23,7 @@ were reconciled first.
@@ -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()` |
| 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`) |
| 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` |
| 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')`,
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
`require.resolve('openvibe-shared/package.json')` and the rest all resolve. It stays one package with
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -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);
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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 });
+}