Skip to content
Open
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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ const webRules = getRulesForPlatform('web');
const backendRules = getRulesForPlatform('backend');
```

## Available Rules (50 total)
## Available Rules (51 total)

### Expo Router Rules

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RuleFunction> = {
'no-relative-paths': noRelativePaths,
Expand Down Expand Up @@ -101,4 +102,5 @@ export const rules: Record<string, RuleFunction> = {
'prefer-named-params': preferNamedParams,
'require-use-client': requireUseClient,
'no-server-import-in-client': noServerImportInClient,
'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 @@ -48,6 +48,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 === true) {

Check failure on line 16 in src/rules/no-unrestricted-loop-in-serverless.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

This expression unnecessarily compares a boolean value to a boolean instead of using it directly
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) {

Check failure on line 64 in src/rules/no-unrestricted-loop-in-serverless.ts

View workflow job for this annotation

GitHub Actions / Lint & Format

This expression unnecessarily compares a boolean value to a boolean instead of using it directly
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(50);
expect(ruleNames.length).toBe(51);
});
});
});
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