From 7b6e531861e3db1c2bc55d8d936cc49efe1d6e37 Mon Sep 17 00:00:00 2001 From: Daniel Chen Date: Mon, 4 May 2026 16:24:43 +0000 Subject: [PATCH] feat: add no-unrestricted-loop-in-serverless lint rule Rebased onto main to resolve conflicts after #51 merge. --- README.md | 16 +- src/rules/index.ts | 2 + src/rules/meta.ts | 1 + .../no-unrestricted-loop-in-serverless.ts | 130 +++++++++++++++++ tests/config-modes.test.ts | 2 +- ...no-unrestricted-loop-in-serverless.test.ts | 137 ++++++++++++++++++ 6 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 src/rules/no-unrestricted-loop-in-serverless.ts create mode 100644 tests/no-unrestricted-loop-in-serverless.test.ts diff --git a/README.md b/README.md index 2c32f34..39ade81 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,16 @@ const backendRules = getRulesForPlatform('backend'); ### General Rules -| Rule | Severity | Platform | Description | -| ------------------------ | -------- | --------- | -------------------------------------------------------------- | -| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | -| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) | -| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | -| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | +| Rule | Severity | Platform | Description | +| ------------------------------------ | -------- | --------- | ---------------------------------------------------------------- | +| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | +| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) | +| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | +| `no-require-statements` | error | backend | Use ES imports, not CommonJS require | +| `no-response-json-lowercase` | warning | backend | Use Response.json() instead of new Response(JSON.stringify()) | +| `sql-no-nested-calls` | error | backend | Don't nest sql template tags | +| `no-sync-fs` | error | backend | Use fs.promises or fs/promises instead of sync fs methods | +| `no-unrestricted-loop-in-serverless` | error | backend | Unbounded loops (while(true), for(;;)) cause serverless timeouts | --- diff --git a/src/rules/index.ts b/src/rules/index.ts index bd58375..035b630 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -52,6 +52,7 @@ import { noServerImportInClient } from './no-server-import-in-client'; import { ssrBrowserApiGuard } from './ssr-browser-api-guard'; import { noReactNativeInWeb } from './no-react-native-in-web'; import { noModuleLevelNew } from './no-module-level-new'; +import { noUnrestrictedLoopInServerless } from './no-unrestricted-loop-in-serverless'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -107,4 +108,5 @@ export const rules: Record = { 'ssr-browser-api-guard': ssrBrowserApiGuard, 'no-react-native-in-web': noReactNativeInWeb, 'no-module-level-new': noModuleLevelNew, + 'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index 060d249..f17b80f 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -51,6 +51,7 @@ export const rulePlatforms: Partial> = { 'no-response-json-lowercase': ['backend'], 'sql-no-nested-calls': ['backend'], 'no-sync-fs': ['backend'], + 'no-unrestricted-loop-in-serverless': ['backend'], // Universal rules (NOT listed here): prefer-guard-clauses, no-type-assertion, // no-string-coerce-error diff --git a/src/rules/no-unrestricted-loop-in-serverless.ts b/src/rules/no-unrestricted-loop-in-serverless.ts new file mode 100644 index 0000000..91fe476 --- /dev/null +++ b/src/rules/no-unrestricted-loop-in-serverless.ts @@ -0,0 +1,130 @@ +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'no-unrestricted-loop-in-serverless'; + +export function noUnrestrictedLoopInServerless(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + WhileStatement(path) { + const { test, loc } = path.node; + + // while (true) + if (t.isBooleanLiteral(test) && test.value) { + if (!hasBreakOrReturn(path)) { + results.push({ + rule: RULE_NAME, + message: + 'Unbounded while(true) loop detected. In serverless functions this will cause a timeout. Add a loop counter, timeout, or maximum iteration limit', + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + return; + } + + // while (1) + if (t.isNumericLiteral(test) && test.value !== 0) { + if (!hasBreakOrReturn(path)) { + results.push({ + rule: RULE_NAME, + message: + 'Unbounded while loop with truthy constant detected. In serverless functions this will cause a timeout. Add a loop counter, timeout, or maximum iteration limit', + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + } + }, + + ForStatement(path) { + const { init, test, update, loc } = path.node; + + // for (;;) — all three parts missing + if (!init && !test && !update) { + if (!hasBreakOrReturn(path)) { + results.push({ + rule: RULE_NAME, + message: + 'Unbounded for(;;) loop detected. In serverless functions this will cause a timeout. Add a loop counter, timeout, or maximum iteration limit', + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + return; + } + + // for (; true; ) — truthy constant test with no update + if (!update && test && t.isBooleanLiteral(test) && test.value) { + if (!hasBreakOrReturn(path)) { + results.push({ + rule: RULE_NAME, + message: + 'Unbounded for loop with no update and truthy test. In serverless functions this will cause a timeout. Add a termination condition', + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + } + }, + }); + + return results; +} + +function hasBreakOrReturn(loopPath: any): boolean { + let found = false; + + loopPath.traverse({ + BreakStatement(innerPath: any) { + // Only count break if it targets this loop (not a nested loop) + const label = innerPath.node.label; + if (!label) { + // Unlabeled break — check it's not inside a nested loop or switch + let parent = innerPath.parentPath; + while (parent && parent !== loopPath) { + const type = parent.node.type; + if ( + type === 'ForStatement' || + type === 'WhileStatement' || + type === 'DoWhileStatement' || + type === 'ForInStatement' || + type === 'ForOfStatement' || + type === 'SwitchStatement' + ) { + return; // break belongs to a nested construct + } + parent = parent.parentPath; + } + found = true; + innerPath.stop(); + } + }, + + ReturnStatement(innerPath: any) { + // Check the return is not inside a nested function + let parent = innerPath.parentPath; + while (parent && parent !== loopPath) { + if ( + parent.node.type === 'FunctionDeclaration' || + parent.node.type === 'FunctionExpression' || + parent.node.type === 'ArrowFunctionExpression' + ) { + return; // return belongs to a nested function + } + parent = parent.parentPath; + } + found = true; + innerPath.stop(); + }, + }); + + return found; +} diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index c18404f..d8bbfed 100644 --- a/tests/config-modes.test.ts +++ b/tests/config-modes.test.ts @@ -123,7 +123,7 @@ describe('config modes', () => { expect(ruleNames).toContain('no-relative-paths'); expect(ruleNames).toContain('expo-image-import'); expect(ruleNames).toContain('no-stylesheet-create'); - expect(ruleNames.length).toBe(53); + expect(ruleNames.length).toBe(54); }); }); }); diff --git a/tests/no-unrestricted-loop-in-serverless.test.ts b/tests/no-unrestricted-loop-in-serverless.test.ts new file mode 100644 index 0000000..49f98c2 --- /dev/null +++ b/tests/no-unrestricted-loop-in-serverless.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['no-unrestricted-loop-in-serverless'] }; + +describe('no-unrestricted-loop-in-serverless rule', () => { + it('should detect while(true) without break', () => { + const code = ` + async function handler(req) { + while (true) { + await fetchData(); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('no-unrestricted-loop-in-serverless'); + expect(results[0].message).toContain('timeout'); + expect(results[0].severity).toBe('error'); + }); + + it('should detect for(;;) without break', () => { + const code = ` + async function handler(req) { + for (;;) { + await poll(); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should detect while(1) without break', () => { + const code = ` + function handler() { + while (1) { + doWork(); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should allow while(true) with break', () => { + const code = ` + async function handler(req) { + while (true) { + const data = await fetchData(); + if (data.done) break; + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow while(true) with return', () => { + const code = ` + async function handler(req) { + while (true) { + const data = await fetchData(); + if (data.done) return data; + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow bounded for loops', () => { + const code = ` + function handler() { + for (let i = 0; i < 100; i++) { + doWork(i); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should allow while with condition', () => { + const code = ` + function handler() { + let count = 0; + while (count < 10) { + doWork(); + count++; + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('should not count break inside nested loop as valid', () => { + const code = ` + function handler() { + while (true) { + for (let i = 0; i < 10; i++) { + if (i === 5) break; + } + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should not count return inside nested function as valid', () => { + const code = ` + function handler() { + while (true) { + const items = data.map((item) => { + return item.name; + }); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('should allow while(false)', () => { + const code = ` + function handler() { + while (false) { + doWork(); + } + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +});