chore(server): lint rule refusing a Promise used as a plain value, and the misses it found

This commit is contained in:
jubnl
2026-09-21 21:39:39 +02:00
parent 953e2526bb
commit d73215f7f6
8 changed files with 427 additions and 7 deletions
+1
View File
@@ -0,0 +1 @@
export {};
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,16 @@
{
// Fixture project for the RuleTester in tests/unit/eslint-rules/. It exists so
// typescript-eslint can build a real program for the virtual `file.ts` the
// tester feeds it. Kept outside src/ and tests/ on purpose: files here must not
// reach `tsc --noEmit`, `tsc -p tsconfig.tests.json` or the type-aware lint
// blocks, since the invalid cases are deliberately broken code.
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": false,
"noEmit": true,
"skipLibCheck": true
},
"include": ["file.ts"]
}
@@ -0,0 +1,15 @@
{
// Second fixture project, identical but with strictNullChecks ON. The server
// compiles with `strict: false`, where `Promise<T> | null` collapses to
// `Promise<T>` and the rule's nullable exemptions are unreachable — this one
// exists so those branches (`p ?? x`, `p === null`) are actually exercised.
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["strict.ts"]
}
+241
View File
@@ -0,0 +1,241 @@
/**
* trek/no-promise-as-value — the blind spot the compiler cannot see.
*
* Phase 1 of the ORM migration turns hundreds of DB-touching methods `async`.
* TypeScript catches a missed `await` only when the promise is used in a way it
* can type-check: `promise.foo` (TS2339), a floating statement
* (no-floating-promises), a condition (no-misused-promises). It catches NOTHING
* when the promise is used as a plain value, because this project compiles with
* `strict: false` / `noImplicitAny: false`:
*
* settings['metric'] // element access on a promise -> `any`
* if (svc.get(id) === 'x') // always false, no error
* `${svc.count(id)}` // "[object Promise]"
* Object.keys(svc.all()) // []
* JSON.stringify(svc.row(id)) // "{}"
* for (const r of svc.rows()) // not iterable, at runtime only
*
* This rule reports those shapes. It is DELIBERATELY CONSERVATIVE and has no
* fixer: it fires only when the resolved type is the global `Promise`/
* `PromiseLike` itself (never a merely-thenable library type such as MikroORM's
* QueryBuilder or supertest's Test, both of which are chained on purpose), only
* when every non-nullish member of a union is one, and never inside
* `Promise.all`/`allSettled`/`race`/`any`, on `.then`/`.catch`/`.finally`, on
* `expect(p).resolves`/`.rejects`, or in an existence/identity check
* (`p === undefined`, `p === null`, `p ?? q`, `a === b` between two promises) —
* see isExistenceOrIdentityCheck below. A finding is a bug: add the `await`.
*/
import ts from 'typescript';
const NULLISH_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void;
const PASSTHROUGH_PROPS = new Set(['then', 'catch', 'finally', 'resolves', 'rejects']);
const COMBINATORS = new Set(['all', 'allSettled', 'race', 'any']);
const EQUALITY_OPERATORS = new Set(['==', '!=', '===', '!==']);
// Callbacks whose result is consumed as a value, where a promise is always a bug
// (every promise is truthy, so the predicate degenerates). `.map`/`.reduce` are
// excluded on purpose: returning promises there feeds Promise.all legitimately.
const PREDICATE_METHODS = new Set([
'filter',
'find',
'findLast',
'findIndex',
'findLastIndex',
'some',
'every',
'sort',
]);
const OBJECT_CONSUMERS = new Set(['keys', 'values', 'entries', 'assign', 'fromEntries']);
/** `String(p)` / `Number(p)` / `Object.keys(p)` / `JSON.stringify(p)` / `Array.isArray(p)`. */
function isValueConsumerCall(callee) {
if (callee.type === 'Identifier') return callee.name === 'String' || callee.name === 'Number';
if (callee.type !== 'MemberExpression' || callee.computed) return false;
const { object, property } = callee;
if (object.type !== 'Identifier' || property.type !== 'Identifier') return false;
if (object.name === 'Object') return OBJECT_CONSUMERS.has(property.name);
if (object.name === 'JSON') return property.name === 'stringify';
if (object.name === 'Array') return property.name === 'isArray';
return false;
}
function isCombinatorCall(node) {
const callee = node.callee;
return (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Promise' &&
callee.property.type === 'Identifier' &&
COMBINATORS.has(callee.property.name)
);
}
/** Inside `Promise.all([...])` a promise IS the value; stop at the function boundary. */
function isInsideCombinator(node) {
for (let cur = node.parent; cur; cur = cur.parent) {
if (cur.type === 'CallExpression' && isCombinatorCall(cur)) return true;
if (cur.type.includes('Function')) return false;
}
return false;
}
function isNullLiteral(node) {
return (node.type === 'Literal' && node.value === null) || (node.type === 'Identifier' && node.name === 'undefined');
}
export default {
meta: {
type: 'problem',
docs: {
description:
'Disallow using a Promise as a plain value (element access, comparison, interpolation, spread, iteration) — the missing-await shapes the compiler cannot see with strict mode off.',
},
schema: [],
messages: {
promiseAsValue: 'Promise used as a value (missing await?): {{ text }}',
},
},
create(context) {
const services = context.sourceCode.parserServices;
if (!services?.program || !services.esTreeNodeToTSNodeMap) return {};
const checker = services.program.getTypeChecker();
const reported = new WeakSet();
const isNullish = (type) => (type.flags & NULLISH_FLAGS) !== 0;
function isPromiseType(type, tsNode) {
if (!type) return false;
if (type.isUnion()) {
const parts = type.types.filter((t) => !isNullish(t));
return parts.length > 0 && parts.every((t) => isPromiseType(t, tsNode));
}
const symbol = type.getSymbol() ?? type.aliasSymbol;
const name = symbol?.getName();
if (name !== 'Promise' && name !== 'PromiseLike') return false;
const then = type.getProperty('then');
if (!then) return false;
return checker.getTypeOfSymbolAtLocation(then, tsNode).getCallSignatures().length > 0;
}
function typeOf(node) {
if (!node || node.type === 'AwaitExpression' || node.type === 'PrivateIdentifier') return null;
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
return tsNode ? { type: checker.getTypeAtLocation(tsNode), tsNode } : null;
}
function isPromise(node) {
const resolved = typeOf(node);
return !!resolved && isPromiseType(resolved.type, resolved.tsNode);
}
function report(node) {
if (!node || reported.has(node) || isInsideCombinator(node)) return;
reported.add(node);
const raw = context.sourceCode.getText(node).replace(/\s+/g, ' ');
const text = raw.length > 60 ? `${raw.slice(0, 57)}...` : raw;
context.report({ node, messageId: 'promiseAsValue', data: { text } });
}
function check(node) {
if (isPromise(node)) report(node);
}
/**
* `p === undefined` / `p === null` is how this codebase asks whether an
* in-flight promise is already parked in a map — a truthiness test there is
* what no-misused-promises rejects, so the explicit comparison IS the
* correct idiom (weather, collab, maps, exchange-rates, idempotency, the
* plugin drain, atlas). `chains.get(userId) === next` likewise asks whether
* the promise it stored is still the current one. Neither can be told apart
* from a missing await by type alone — with strictNullChecks off `Map.get()`
* is not even typed as nullable — so equality against nullish, and equality
* between two promises, are both exempt. `p === 'x'` still reports.
*/
function isExistenceOrIdentityCheck(node) {
if (isNullLiteral(node.left) || isNullLiteral(node.right)) return true;
return isPromise(node.left) && isPromise(node.right);
}
return {
MemberExpression(node) {
const key =
node.property.type === 'Identifier' && !node.computed
? node.property.name
: node.property.type === 'Literal'
? node.property.value
: undefined;
if (typeof key === 'string' && PASSTHROUGH_PROPS.has(key)) return;
check(node.object);
},
BinaryExpression(node) {
// `p instanceof Promise` is the legitimate way to ask.
if (node.operator === 'instanceof') return;
if (EQUALITY_OPERATORS.has(node.operator) && isExistenceOrIdentityCheck(node)) return;
check(node.left);
check(node.right);
},
LogicalExpression(node) {
// `p ?? x` is the same existence check as `p === undefined`, and with
// strictNullChecks off the type cannot say whether it is a real one.
if (node.operator === '??') return;
// Only the left operand of `&&`/`||`: it is the one evaluated as a test,
// where a promise is unconditionally truthy. The right operand IS the
// expression's value and gets caught at whatever consumes it.
check(node.left);
},
TemplateLiteral(node) {
node.expressions.forEach(check);
},
SpreadElement(node) {
check(node.argument);
},
UnaryExpression(node) {
if (node.operator === 'typeof') check(node.argument);
},
ForOfStatement(node) {
if (!node.await) check(node.right);
},
ForInStatement(node) {
check(node.right);
},
CallExpression(node) {
if (isValueConsumerCall(node.callee)) {
node.arguments.forEach((arg) => {
if (arg.type !== 'SpreadElement') check(arg);
});
return;
}
const callee = node.callee;
if (
callee.type !== 'MemberExpression' ||
callee.computed ||
callee.property.type !== 'Identifier' ||
!PREDICATE_METHODS.has(callee.property.name)
) {
return;
}
for (const arg of node.arguments) {
if (arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') continue;
const resolved = typeOf(arg);
if (!resolved) continue;
const signatures = resolved.type.getCallSignatures();
if (
signatures.length > 0 &&
signatures.every((sig) => isPromiseType(checker.getReturnTypeOfSignature(sig), resolved.tsNode))
) {
report(arg.body.type === 'BlockStatement' ? arg : arg.body);
}
}
},
};
},
};
+12
View File
@@ -4,6 +4,11 @@ import gitignore from 'eslint-config-flat-gitignore';
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from 'typescript-eslint';
import noPromiseAsValue from './eslint-rules/no-promise-as-value.mjs';
// Local rules live in eslint-rules/ and are exposed under the `trek/` prefix.
const trek = { rules: { 'no-promise-as-value': noPromiseAsValue } };
export default tseslint.config(
gitignore({ strict: false }),
{
@@ -56,7 +61,14 @@ export default tseslint.config(
},
{
files: ['src/**/*.ts', 'tests/**/*.ts'],
plugins: { trek },
rules: {
// trek/no-promise-as-value closes the hole the two rules below leave open:
// with `strict: false`, a promise used as a plain VALUE (element access,
// comparison, interpolation, spread, iteration, Object.keys/JSON.stringify)
// type-checks clean and silently produces `any`, `false` or `[object
// Promise]`. See eslint-rules/no-promise-as-value.mjs.
'trek/no-promise-as-value': 'error',
// --- The promise safety net for the Phase 1 async sweep (ORM migration) ---
// Turning a DB-touching method `async` makes every unawaited call site a
// silently reordered write, and every `if (svc.exists(id))` permanently
@@ -0,0 +1,134 @@
import noPromiseAsValue from '../../../eslint-rules/no-promise-as-value.mjs';
import tsParser from '@typescript-eslint/parser';
import { RuleTester, type Rule } from 'eslint';
import path from 'path';
// The rule only exists because it is TYPE-aware, so the tester runs against a
// real TypeScript program: eslint-rules/fixtures/ holds a tsconfig whose single
// file is the one the tester feeds code into. It lives outside src/ and tests/
// so the deliberately-broken snippets below never reach tsc or the lint gates.
const fixtureRoot = path.resolve(__dirname, '../../../eslint-rules/fixtures');
const ruleTester = new RuleTester({
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: fixtureRoot,
},
},
});
const PRELUDE = `
declare function getSettings(id: number): Promise<Record<string, string>>;
declare function getName(id: number): Promise<string>;
declare function getCount(): Promise<number>;
declare function getRows(): Promise<string[]>;
declare function maybeSettings(id: number): Promise<Record<string, string>> | null;
declare function syncName(id: number): string;
declare const rows: { id: number; active: boolean }[];
declare const expect: (value: unknown) => { resolves: { toBe(value: unknown): void } };
`;
/** Every case shares the declarations above; offsets never matter, only counts. */
const withPrelude = (code: string) => `${PRELUDE}${code}\n`;
const valid = [
// Awaited first — the shape every finding is fixed into.
`const s = await getSettings(1); const v = s['metric']; void v;`,
`const hit = (await getName(1)) === 'x'; void hit;`,
`const msg = \`\${await getCount()}\`; void msg;`,
`const all = [...(await getRows())]; void all;`,
`for (const r of await getRows()) { void r; }`,
`const keys = Object.keys(await getSettings(1)); void keys;`,
// Promise-shaped uses that are correct by construction.
`const n = getName(1).then((x) => x.length); void n;`,
`const both = await Promise.all([getName(1), getCount()]); void both;`,
`const joined = \`\${(await Promise.all([getName(1)]))[0]}\`; void joined;`,
`expect(getName(1)).resolves.toBe('x');`,
`const isP = getName(1) instanceof Promise; void isP;`,
// Existence / identity checks on a parked promise: the in-flight-map idiom the
// server uses everywhere, and the one a truthiness test would get wrong.
`const inflight = new Map<string, Promise<string>>(); const hit = inflight.get('k'); if (hit !== undefined) { void hit; }`,
`let building: Promise<string> = getName(1); if (building === null) { void 0; }`,
`const chains = new Map<number, Promise<void>>(); const next = Promise.resolve(); if (chains.get(1) === next) { void 0; }`,
`const chains = new Map<number, Promise<void>>(); const prev = chains.get(1) ?? Promise.resolve(); void prev;`,
`void getName(1);`,
// Non-promise values, and the array callbacks a promise legitimately flows through.
`const same = syncName(1) === 'x'; void same;`,
`const active = rows.filter((r) => r.active); void active;`,
`const pending = rows.map((r) => getName(r.id)); void pending;`,
].map((code) => ({ code: withPrelude(code), filename: path.join(fixtureRoot, 'file.ts') }));
const invalid = [
// Element access: compiles to `any` with strict mode off — the live miss Task 2 found.
`const v = getSettings(1)['metric']; void v;`,
// Property access on a promise-typed object, bracket-free.
`const v = getSettings(1).metric; void v;`,
// Comparisons: always false, no error.
`if (getName(1) === 'x') { void 0; }`,
`const n = getCount() + 1; void n;`,
`const has = 'metric' in getSettings(1); void has;`,
// Interpolation: "[object Promise]".
`const msg = \`\${getCount()}\`; void msg;`,
// Spread: neither iterable nor enumerable at runtime.
`const a = [...getRows()]; void a;`,
`const o = { ...getSettings(1) }; void o;`,
// Plain-value consumers.
`Object.keys(getSettings(1));`,
`JSON.stringify(getSettings(1));`,
`Array.isArray(getRows());`,
`const t = typeof getName(1); void t;`,
// Iteration.
`for (const r of getRows()) { void r; }`,
`for (const k in getSettings(1)) { void k; }`,
// Logical left operand: always truthy, so the fallback is dead code.
`const v = getName(1) || 'x'; void v;`,
// A predicate callback that returns a promise: every element passes.
`const active = rows.filter((r) => getName(r.id)); void active;`,
].map((code) => ({
code: withPrelude(code),
filename: path.join(fixtureRoot, 'file.ts'),
errors: [{ messageId: 'promiseAsValue' }],
}));
ruleTester.run('trek/no-promise-as-value', noPromiseAsValue as unknown as Rule.RuleModule, {
valid,
invalid,
});
// Under strictNullChecks a promise-returning call can genuinely be nullable, and
// `p ?? x` / `p === null` are then real checks rather than missing awaits. The
// server compiles with `strict: false`, where that union collapses and these
// branches are unreachable — so they get their own project to be tested in.
const strictTester = new RuleTester({
languageOptions: {
parser: tsParser,
parserOptions: { project: './tsconfig.strict.json', tsconfigRootDir: fixtureRoot },
},
});
const strictFile = path.join(fixtureRoot, 'strict.ts');
strictTester.run('trek/no-promise-as-value (strictNullChecks)', noPromiseAsValue as unknown as Rule.RuleModule, {
valid: [
{ code: withPrelude(`const f = maybeSettings(1) ?? null; void f;`), filename: strictFile },
{ code: withPrelude(`const missing = maybeSettings(1) === null; void missing;`), filename: strictFile },
],
invalid: [
{
// Nullable or not, indexing it is still a missing await.
code: withPrelude(`const v = maybeSettings(1)!['metric']; void v;`),
filename: strictFile,
errors: [{ messageId: 'promiseAsValue' }],
},
{
// A union of `Promise<T> | null` is still a promise once the nullish
// members are stripped — interpolating it is "[object Promise]".
code: withPrelude(`const msg = \`\${maybeSettings(1)}\`; void msg;`),
filename: strictFile,
errors: [{ messageId: 'promiseAsValue' }],
},
],
});
@@ -362,8 +362,8 @@ describe('instance-scope actions (admin)', () => {
.run(pluginId, key, key, scope);
}
const adminReq = { user: { id: 42 } } as unknown as Request;
function controller(invoke = vi.fn(async () => ({ ok: true, message: 'pong' }))) {
const rt = createPluginRuntime(new DatabaseService(dbConn));
async function controller(invoke = vi.fn(async () => ({ ok: true, message: 'pong' }))) {
const rt = await createPluginRuntime(new DatabaseService(dbConn));
// isActive normally reflects the supervisor's live child map, which nothing here
// spawns — so it's stubbed to read the same DB status the test itself flips,
// mirroring what an actually-activated plugin would report.
@@ -375,25 +375,25 @@ describe('instance-scope actions (admin)', () => {
beforeEach(() => { testDb.prepare('DELETE FROM plugin_actions').run(); });
it('ACT-ADM-001 — GET config lists the instance actions and none of the user ones', () => {
it('ACT-ADM-001 — GET config lists the instance actions and none of the user ones', async () => {
install('p');
declareAction('p', 'purge', 'instance');
declareAction('p', 'testConnection', 'user');
const { c } = controller();
const { c } = await controller();
expect(c.getConfig('p').actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
it('ACT-ADM-002 — POST runs the action as the clicking admin in the instance scope', async () => {
install('p');
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run();
const { c, invoke } = controller();
const { c, invoke } = await controller();
expect(await c.runAction('p', 'purge', adminReq)).toEqual({ ok: true, message: 'pong' });
expect(invoke).toHaveBeenCalledWith('p', 'purge', 42, 'instance');
});
it('ACT-ADM-003 — an inactive plugin answers 404 like the user route', async () => {
install('p');
const { c, invoke } = controller();
const { c, invoke } = await controller();
await expect(c.runAction('p', 'purge', adminReq)).rejects.toMatchObject({ status: 404, response: { error: 'Plugin is not active' } });
expect(invoke).not.toHaveBeenCalled();
});
@@ -401,7 +401,7 @@ describe('instance-scope actions (admin)', () => {
it('ACT-ADM-004 — a refused key is a failed RESULT, not a server error', async () => {
install('p');
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run();
const { c } = controller(vi.fn(async () => { throw new Error('plugin p did not declare action "x" in scope instance'); }));
const { c } = await controller(vi.fn(async () => { throw new Error('plugin p did not declare action "x" in scope instance'); }));
expect(await c.runAction('p', 'x', adminReq)).toEqual({ ok: false, message: 'plugin p did not declare action "x" in scope instance' });
});
});