Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
2 changes: 2 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RuleFunction> = {
'no-relative-paths': noRelativePaths,
Expand Down Expand Up @@ -107,4 +108,5 @@ export const rules: Record<string, RuleFunction> = {
'ssr-browser-api-guard': ssrBrowserApiGuard,
'no-react-native-in-web': noReactNativeInWeb,
'no-module-level-new': noModuleLevelNew,
'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless,
};
1 change: 1 addition & 0 deletions src/rules/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const rulePlatforms: Partial<Record<string, Platform[]>> = {
'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
Expand Down
130 changes: 130 additions & 0 deletions src/rules/no-unrestricted-loop-in-serverless.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion tests/config-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
137 changes: 137 additions & 0 deletions tests/no-unrestricted-loop-in-serverless.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading