diff --git a/README.md b/README.md index 89390df..391a538 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (50 total) +## Available Rules (51 total) ### Expo Router Rules @@ -186,12 +186,13 @@ const backendRules = getRulesForPlatform('backend'); ### Backend / SQL Rules -| Rule | Severity | Platform | Description | -| ---------------------------- | -------- | -------- | ------------------------------------------------------------- | -| `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 | +| Rule | Severity | Platform | Description | +| ------------------------------------ | -------- | -------- | ---------------------------------------------------------------- | +| `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 | ### URL Rules diff --git a/src/rules/index.ts b/src/rules/index.ts index dd01b2a..a8f42a5 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -49,6 +49,7 @@ import { noSyncFs } from './no-sync-fs'; import { preferNamedParams } from './prefer-named-params'; import { requireUseClient } from './require-use-client'; import { noServerImportInClient } from './no-server-import-in-client'; +import { noUnrestrictedLoopInServerless } from './no-unrestricted-loop-in-serverless'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -101,4 +102,5 @@ export const rules: Record = { 'prefer-named-params': preferNamedParams, 'require-use-client': requireUseClient, 'no-server-import-in-client': noServerImportInClient, + 'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index ef0a579..36c5eb8 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -48,6 +48,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..92fe2c2 --- /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 === true) { + 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 === true) { + 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 112bd5e..81092d3 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(50); + expect(ruleNames.length).toBe(51); }); }); }); 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); + }); +});