From 436fc3b7f3533386f93d54d2a9bb336a89ab0e39 Mon Sep 17 00:00:00 2001 From: Pooya Paridel Date: Tue, 11 Nov 2025 21:00:46 -0800 Subject: [PATCH] feat(eslint-plugin): add no-closure-in-durable-operations rule and reorganize structure - Add new rule to detect and prevent closure variable mutations in durable operations (step, runInChildContext, waitForCondition, waitForCallback) - Rule allows reading closure variables but prevents assignments and updates (=, +=, ++, --) - Recursively tracks variable declarations across nested scopes - Supports function parameter overloads (function as 1st or 2nd parameter) - Add comprehensive inline documentation with examples for better code readability - Reorganize rules into subdirectories with rule implementation and tests co-located - Add unit tests for all rules (34 tests total) - Add integration tests using ESLint RuleTester with real TypeScript code (34 tests total) - Fix duplicate error reporting by checking only assignment/update expressions - Update README with new rule documentation and examples - Total: 68 tests (34 unit + 34 integration) --- .../README.md | 82 +- .../jest.config.js | 8 +- .../src/index.ts | 7 +- ...losure-in-durable-operations.integ.test.ts | 313 ++++++ .../no-closure-in-durable-operations.test.ts | 950 ++++++++++++++++++ .../no-closure-in-durable-operations.ts | 346 +++++++ .../no-nested-durable-operations.test.ts | 2 +- .../no-nested-durable-operations.ts | 0 ...n-deterministic-outside-step.integ.test.ts | 198 ++++ .../no-non-deterministic-outside-step.test.ts | 2 +- .../no-non-deterministic-outside-step.ts | 0 11 files changed, 1893 insertions(+), 15 deletions(-) create mode 100644 packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.integ.test.ts create mode 100644 packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.test.ts create mode 100644 packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.ts rename packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/{__tests__ => no-nested-durable-operations}/no-nested-durable-operations.test.ts (98%) rename packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/{ => no-nested-durable-operations}/no-nested-durable-operations.ts (100%) create mode 100644 packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.integ.test.ts rename packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/{__tests__ => no-non-deterministic-outside-step}/no-non-deterministic-outside-step.test.ts (97%) rename packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/{ => no-non-deterministic-outside-step}/no-non-deterministic-outside-step.ts (100%) diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/README.md b/packages/aws-durable-execution-sdk-js-eslint-plugin/README.md index a99b080d..c6cf24df 100644 --- a/packages/aws-durable-execution-sdk-js-eslint-plugin/README.md +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/README.md @@ -16,7 +16,8 @@ Add the plugin to your ESLint configuration: { "plugins": ["@aws/durable-execution-sdk-js-eslint-plugin"], "rules": { - "@aws/durable-execution-sdk-js-eslint-plugin/no-nested-durable-operations": "error" + "@aws/durable-execution-sdk-js-eslint-plugin/no-nested-durable-operations": "error", + "@aws/durable-execution-sdk-js-eslint-plugin/no-closure-in-durable-operations": "error" } } ``` @@ -65,22 +66,93 @@ const result = await context.runInChildContext("block1", async (childCtx) => { }); ``` +### `no-closure-in-durable-operations` + +Prevents modifying closure variables inside durable operations. Reading closure variables is allowed. + +#### ❌ Incorrect + +```javascript +const handler = withDurableExecution( + async (event: any, context: DurableContext) => { + let a = 2; + const result = await context.runInChildContext( + async (childContext: DurableContext) => { + const stepResult = await childContext.step(async () => { + // Error: Modifying 'a' from outer scope causes replay inconsistency + a = a + 1; + return "child step completed"; + }); + return stepResult; + }, + ); + return result; + }, +); +``` + +#### ✅ Correct + +```javascript +const handler = withDurableExecution( + async (event: any, context: DurableContext) => { + let a = 2; + const result = await context.runInChildContext( + async (childContext: DurableContext) => { + const stepResult = await childContext.step(async () => { + // Reading 'a' is OK + const value = a + 1; + return value; + }); + return stepResult; + }, + ); + return result; + }, +); + +// Or use local variables +const handler = withDurableExecution( + async (event: any, context: DurableContext) => { + const result = await context.runInChildContext( + async (childContext: DurableContext) => { + const stepResult = await childContext.step(async () => { + let a = 2; + a = a + 1; + return "child step completed"; + }); + return stepResult; + }, + ); + return result; + }, +); +``` + ## Supported Durable Operations The plugin detects these durable operations: - `step` -- `wait` -- `waitForCallback` +- `runInChildContext` - `waitForCondition` +- `waitForCallback` +- `wait` - `parallel` - `map` -- `runInChildContext` -## Why This Rule? +## Why These Rules? + +### No Nested Durable Operations Nesting durable operations with the same context object can cause runtime errors and unexpected behavior in AWS Lambda Durable Functions. This rule helps catch these issues at development time. +### No Closure in Durable Operations + +During replay, durable functions skip already-executed steps. If a closure variable is modified inside a step, the modification won't occur during replay, causing different outcomes between initial execution and replay. This leads to non-deterministic behavior and potential bugs. + +Reading closure variables is safe because it doesn't change state. Only mutations (assignments, increments, decrements) are problematic. + ## License Apache-2.0 diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/jest.config.js b/packages/aws-durable-execution-sdk-js-eslint-plugin/jest.config.js index d45791c3..67dc3851 100644 --- a/packages/aws-durable-execution-sdk-js-eslint-plugin/jest.config.js +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/jest.config.js @@ -2,10 +2,6 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", roots: ["/src"], - testMatch: ["**/__tests__/**/*.test.ts"], - collectCoverageFrom: [ - "src/**/*.ts", - "!src/**/*.test.ts", - "!src/**/__tests__/**", - ], + testMatch: ["**/*.test.ts"], + collectCoverageFrom: ["src/**/*.ts", "!src/**/*.test.ts"], }; diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/index.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/index.ts index 0809ba26..484c6b02 100644 --- a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/index.ts +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/index.ts @@ -1,10 +1,12 @@ -import { noNestedDurableOperations } from "./rules/no-nested-durable-operations"; -import { noNonDeterministicOutsideStep } from "./rules/no-non-deterministic-outside-step"; +import { noNestedDurableOperations } from "./rules/no-nested-durable-operations/no-nested-durable-operations"; +import { noNonDeterministicOutsideStep } from "./rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step"; +import { noClosureInDurableOperations } from "./rules/no-closure-in-durable-operations/no-closure-in-durable-operations"; export = { rules: { "no-nested-durable-operations": noNestedDurableOperations, "no-non-deterministic-outside-step": noNonDeterministicOutsideStep, + "no-closure-in-durable-operations": noClosureInDurableOperations, }, configs: { recommended: { @@ -12,6 +14,7 @@ export = { rules: { "@aws/durable-functions/no-nested-durable-operations": "error", "@aws/durable-functions/no-non-deterministic-outside-step": "error", + "@aws/durable-functions/no-closure-in-durable-operations": "error", }, }, }, diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.integ.test.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.integ.test.ts new file mode 100644 index 00000000..8ce8ab86 --- /dev/null +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.integ.test.ts @@ -0,0 +1,313 @@ +import { RuleTester } from "eslint"; +import { noClosureInDurableOperations } from "./no-closure-in-durable-operations"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +} as any); + +describe("no-closure-in-durable-operations integration tests", () => { + ruleTester.run( + "no-closure-in-durable-operations", + noClosureInDurableOperations, + { + valid: [ + // Reading closure variables is allowed + { + code: ` + async function handler(event: any, context: DurableContext) { + let counter = 0; + await context.step(async () => { + return counter + 1; + }); + } + `, + }, + // Using local variables is allowed + { + code: ` + async function handler(event: any, context: DurableContext) { + await context.step(async () => { + let counter = 0; + counter++; + return counter; + }); + } + `, + }, + // Reading in runInChildContext is allowed + { + code: ` + async function handler(event: any, context: DurableContext) { + const userId = event.userId; + await context.runInChildContext(async (ctx) => { + return await fetchUser(userId); + }); + } + `, + }, + // Function parameters can be modified + { + code: ` + async function handler(event: any, context: DurableContext) { + await context.step(async (ctx) => { + ctx = null; + return "done"; + }); + } + `, + }, + // Named step with reading + { + code: ` + async function handler(event: any, context: DurableContext) { + let value = 10; + await context.step("myStep", async () => { + return value * 2; + }); + } + `, + }, + // waitForCondition with reading + { + code: ` + async function handler(event: any, context: DurableContext) { + let threshold = 100; + await context.waitForCondition(async () => { + return getCount() > threshold; + }); + } + `, + }, + ], + invalid: [ + // Direct assignment + { + code: ` + async function handler(event: any, context: DurableContext) { + let counter = 0; + await context.step(async () => { + counter = 5; + return counter; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "counter" }, + }, + ], + }, + // Increment operator + { + code: ` + async function handler(event: any, context: DurableContext) { + let counter = 0; + await context.step(async () => { + counter++; + return counter; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "counter" }, + }, + ], + }, + // Pre-increment + { + code: ` + async function handler(event: any, context: DurableContext) { + let counter = 0; + await context.step(async () => { + ++counter; + return counter; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "counter" }, + }, + ], + }, + // Decrement + { + code: ` + async function handler(event: any, context: DurableContext) { + let counter = 10; + await context.step(async () => { + counter--; + return counter; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "counter" }, + }, + ], + }, + // Compound assignment + { + code: ` + async function handler(event: any, context: DurableContext) { + let total = 0; + await context.step(async () => { + total += 10; + return total; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "total" }, + }, + ], + }, + // Named step with mutation + { + code: ` + async function handler(event: any, context: DurableContext) { + let value = 10; + await context.step("myStep", async () => { + value = 20; + return value; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "value" }, + }, + ], + }, + // runInChildContext with mutation + { + code: ` + async function handler(event: any, context: DurableContext) { + let result = null; + await context.runInChildContext(async (ctx) => { + result = await fetchData(); + return result; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "result" }, + }, + ], + }, + // waitForCondition with mutation + { + code: ` + async function handler(event: any, context: DurableContext) { + let attempts = 0; + await context.waitForCondition(async () => { + attempts++; + return attempts > 5; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "attempts" }, + }, + ], + }, + // waitForCallback with mutation + { + code: ` + async function handler(event: any, context: DurableContext) { + let callbackData = null; + await context.waitForCallback(async (resolve) => { + callbackData = "received"; + resolve(); + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "callbackData" }, + }, + ], + }, + // Nested step with mutation (reported in both contexts) + { + code: ` + async function handler(event: any, context: DurableContext) { + let counter = 0; + await context.runInChildContext(async (ctx) => { + await ctx.step(async () => { + counter++; + return counter; + }); + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "counter" }, + }, + { + messageId: "closureVariableUsage", + data: { variableName: "counter" }, + }, + ], + }, + // Multiple mutations + { + code: ` + async function handler(event: any, context: DurableContext) { + let a = 0; + let b = 0; + await context.step(async () => { + a++; + b = 10; + return a + b; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "a" }, + }, + { + messageId: "closureVariableUsage", + data: { variableName: "b" }, + }, + ], + }, + // Function parameter from outer scope + { + code: ` + async function handler(event: any, context: DurableContext) { + await context.step(async () => { + event = null; + return "done"; + }); + } + `, + errors: [ + { + messageId: "closureVariableUsage", + data: { variableName: "event" }, + }, + ], + }, + ], + }, + ); +}); diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.test.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.test.ts new file mode 100644 index 00000000..34cec8de --- /dev/null +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.test.ts @@ -0,0 +1,950 @@ +import { noClosureInDurableOperations } from "./no-closure-in-durable-operations"; + +describe("no-closure-in-durable-operations", () => { + it("should be defined", () => { + expect(noClosureInDurableOperations).toBeDefined(); + expect(noClosureInDurableOperations.meta).toBeDefined(); + expect(noClosureInDurableOperations.create).toBeDefined(); + }); + + it("should have correct meta information", () => { + const meta = noClosureInDurableOperations.meta!; + expect(meta.type).toBe("problem"); + expect(meta.docs?.description).toContain("closure variables"); + expect(meta.messages?.closureVariableUsage).toBeDefined(); + }); + + describe("should detect mutations", () => { + it("should detect direct assignment (a = value)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const assignment: any = { + type: "AssignmentExpression", + operator: "=", + left: identifier, + }; + identifier.parent = assignment; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: assignment }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + assignment.parent = stepCallback.body.body[0]; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalledWith({ + node: identifier, + messageId: "closureVariableUsage", + data: { variableName: "a" }, + }); + }); + + it("should detect compound assignment (a += 1)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const assignment: any = { + type: "AssignmentExpression", + operator: "+=", + left: identifier, + }; + identifier.parent = assignment; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: assignment }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + + it("should detect increment (a++)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "++", + argument: identifier, + prefix: false, + }; + identifier.parent = updateExpr; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + + it("should detect pre-increment (++a)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "++", + argument: identifier, + prefix: true, + }; + identifier.parent = updateExpr; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + + it("should detect decrement (a--)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "--", + argument: identifier, + prefix: false, + }; + identifier.parent = updateExpr; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + }); + + describe("should allow reads", () => { + it("should allow reading closure variable", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const returnStmt: any = { + type: "ReturnStatement", + argument: identifier, + }; + identifier.parent = returnStmt; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [returnStmt], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).not.toHaveBeenCalled(); + }); + }); + + describe("should handle variable scopes", () => { + it("should not report if variable is declared in callback params", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "ctx" }; + const assignment: any = { + type: "AssignmentExpression", + left: identifier, + }; + identifier.parent = assignment; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [{ type: "Identifier", name: "ctx" }], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: assignment }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { type: "BlockStatement", body: [] }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "runInChildContext" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).not.toHaveBeenCalled(); + }); + + it("should not report if variable is declared in callback body", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "local" }; + const assignment: any = { + type: "AssignmentExpression", + left: identifier, + }; + identifier.parent = assignment; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "local" }, + }, + ], + }, + { type: "ExpressionStatement", expression: assignment }, + ], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { type: "BlockStatement", body: [] }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).not.toHaveBeenCalled(); + }); + + it("should not report if variable is declared in nested block within callback", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "blockVar" }; + const assignment: any = { + type: "AssignmentExpression", + left: identifier, + }; + identifier.parent = assignment; + + const ifBlock: any = { + type: "IfStatement", + consequent: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "blockVar" }, + }, + ], + }, + ], + }, + }; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + ifBlock, + { type: "ExpressionStatement", expression: assignment }, + ], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { type: "BlockStatement", body: [] }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).not.toHaveBeenCalled(); + }); + }); + + describe("should work with runInChildContext", () => { + it("should detect mutation in runInChildContext callback", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "a" }; + const assignment: any = { + type: "AssignmentExpression", + left: identifier, + }; + identifier.parent = assignment; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [{ type: "Identifier", name: "ctx" }], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: assignment }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "a" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "runInChildContext" }, + }, + arguments: [stepCallback], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + }); + + describe("should work with waitForCondition", () => { + it("should detect mutation in waitForCondition callback", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "counter" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "++", + argument: identifier, + }; + identifier.parent = updateExpr; + + const conditionCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "counter" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "waitForCondition" }, + }, + arguments: [conditionCallback], + parent: outerFunction, + }; + + conditionCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + }); + + describe("should work with waitForCallback", () => { + it("should detect mutation in waitForCallback callback", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "result" }; + const assignment: any = { + type: "AssignmentExpression", + left: identifier, + }; + identifier.parent = assignment; + + const callbackFn: any = { + type: "ArrowFunctionExpression", + params: [{ type: "Identifier", name: "resolve" }], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: assignment }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "result" }, + }, + ], + }, + ], + }, + }; + + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "waitForCallback" }, + }, + arguments: [callbackFn], + parent: outerFunction, + }; + + callbackFn.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + }); + + describe("should handle function parameter overloads", () => { + it("should detect mutation when function is 1st parameter (no name)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "counter" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "++", + argument: identifier, + }; + identifier.parent = updateExpr; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "counter" }, + }, + ], + }, + ], + }, + }; + + // context.step(async () => { counter++; }) + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [stepCallback], // Function as 1st argument + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + + it("should detect mutation when function is 2nd parameter (with name)", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "counter" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "++", + argument: identifier, + }; + identifier.parent = updateExpr; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "counter" }, + }, + ], + }, + ], + }, + }; + + // context.step("stepName", async () => { counter++; }) + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [ + { type: "Literal", value: "stepName" }, // Name as 1st argument + stepCallback, // Function as 2nd argument + ], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + + it("should detect mutation when function is 2nd parameter with config as 3rd", () => { + const mockContext = { + report: jest.fn(), + getSourceCode: jest.fn(() => ({ ast: { tokens: [] } })), + }; + + const rule = noClosureInDurableOperations.create(mockContext as any); + + const identifier: any = { type: "Identifier", name: "counter" }; + const updateExpr: any = { + type: "UpdateExpression", + operator: "++", + argument: identifier, + }; + identifier.parent = updateExpr; + + const stepCallback: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [{ type: "ExpressionStatement", expression: updateExpr }], + }, + }; + + const outerFunction: any = { + type: "ArrowFunctionExpression", + params: [], + body: { + type: "BlockStatement", + body: [ + { + type: "VariableDeclaration", + declarations: [ + { + type: "VariableDeclarator", + id: { type: "Identifier", name: "counter" }, + }, + ], + }, + ], + }, + }; + + // context.step("stepName", async () => { counter++; }, { retry: 3 }) + const callExpression: any = { + type: "CallExpression", + callee: { + type: "MemberExpression", + property: { type: "Identifier", name: "step" }, + }, + arguments: [ + { type: "Literal", value: "stepName" }, + stepCallback, + { type: "ObjectExpression", properties: [] }, // Config object + ], + parent: outerFunction, + }; + + stepCallback.parent = callExpression; + + rule.CallExpression?.(callExpression); + + expect(mockContext.report).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.ts new file mode 100644 index 00000000..0af7f2f4 --- /dev/null +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-closure-in-durable-operations/no-closure-in-durable-operations.ts @@ -0,0 +1,346 @@ +import { Rule } from "eslint"; + +/** + * ESLint rule to prevent modifying closure variables inside durable operations. + * + * Why this matters: + * During replay, durable functions skip already-executed steps. If a closure variable + * is modified inside a step, the modification won't occur during replay, causing + * different outcomes between initial execution and replay. + * + * Example of problematic code: + * let counter = 0; + * await context.step(async () => { + * counter++; // ❌ This won't execute during replay! + * }); + * + * Example of safe code: + * let counter = 0; + * await context.step(async () => { + * return counter + 1; // ✅ Reading is safe + * }); + */ +export const noClosureInDurableOperations: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "Disallow modifying closure variables inside durable operations", + category: "Possible Errors", + recommended: true, + }, + messages: { + closureVariableUsage: + 'Variable "{{variableName}}" from outer scope should not be modified inside durable operations. It may cause inconsistent behavior during replay.', + }, + schema: [], + }, + create(context) { + // Durable operations that accept callbacks where mutations could cause issues + const durableOperations = new Set([ + "step", + "runInChildContext", + "waitForCondition", + "waitForCallback", + ]); + + /** + * Checks if a node is a durable operation call. + * + * Example: context.step(...) or ctx.runInChildContext(...) + * + * @param node - AST node to check + * @returns true if node is a durable operation call + */ + function isDurableOperation(node: any): boolean { + return ( + node.type === "CallExpression" && + node.callee?.type === "MemberExpression" && + node.callee.property?.type === "Identifier" && + durableOperations.has(node.callee.property.name) + ); + } + + /** + * Extracts the callback function from a durable operation call. + * + * Example: + * context.step(async () => { ... }) + * ^^^^^^^^^^^^^^^^^ returns this function + * + * @param node - CallExpression node + * @returns The callback function node, or null if not found + */ + function getCallbackFunction(node: any): any { + if (!isDurableOperation(node)) return null; + + const args = node.arguments; + for (const arg of args) { + if ( + arg.type === "ArrowFunctionExpression" || + arg.type === "FunctionExpression" + ) { + return arg; + } + } + return null; + } + + /** + * Collects all variable names declared within a scope (including nested scopes). + * + * This includes: + * - Function parameters: async (ctx) => { ... } + * - Top-level declarations: const x = 1; + * - Nested block declarations: if (true) { let y = 2; } + * - Loop variables: for (let i = 0; ...) + * + * Example: + * async (ctx) => { + * const x = 1; + * if (true) { + * let y = 2; + * } + * } + * Returns: Set(['ctx', 'x', 'y']) + * + * @param scopeNode - Function or block node to analyze + * @returns Set of variable names declared in this scope + */ + function getVariablesDeclaredInScope(scopeNode: any): Set { + const declared = new Set(); + + // Add function parameters + // Example: async (ctx, resolve) => { ... } + // ^^^ ^^^^^^^ + if (scopeNode.params) { + scopeNode.params.forEach((param: any) => { + if (param.type === "Identifier") { + declared.add(param.name); + } + }); + } + + // Recursively walk the entire callback body to find all variable declarations + // This catches variables in nested blocks, loops, try-catch, etc. + function walkForDeclarations(node: any) { + if (!node) return; + + // Found a variable declaration + // Example: const x = 1; or let y = 2; + if (node.type === "VariableDeclaration") { + node.declarations.forEach((decl: any) => { + if (decl.id?.type === "Identifier") { + declared.add(decl.id.name); + } + }); + } + + // Walk all child nodes to find nested declarations + for (const key in node) { + if (key === "parent") continue; // Skip parent references to avoid cycles + const child = node[key]; + if (Array.isArray(child)) { + child.forEach(walkForDeclarations); + } else if (child && typeof child === "object") { + walkForDeclarations(child); + } + } + } + + if (scopeNode.body) { + walkForDeclarations(scopeNode.body); + } + + return declared; + } + + /** + * Finds all variables declared in outer (parent) scopes. + * + * Walks up the AST tree to find variables declared in enclosing functions. + * + * Example: + * async (event, context) => { + * let counter = 0; + * await context.step(async () => { + * // From here, outer variables are: event, context, counter + * }); + * } + * + * @param callbackNode - The callback function node + * @returns Set of variable names from outer scopes + */ + function findOuterScopeVariables(callbackNode: any): Set { + const outerVars = new Set(); + let current = callbackNode.parent; + + // Walk up the tree until we reach the root + while (current) { + // Check if this is a function scope + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionExpression" || + current.type === "FunctionDeclaration" + ) { + // Add function parameters + // Example: async (event, context) => { ... } + if (current.params) { + current.params.forEach((param: any) => { + if (param.type === "Identifier") { + outerVars.add(param.name); + } + }); + } + + // Add variables declared in this function's body + // Example: const result = await fetch(); + if (current.body?.type === "BlockStatement") { + const body = current.body.body; + for (const stmt of body) { + if (stmt.type === "VariableDeclaration") { + stmt.declarations.forEach((decl: any) => { + if (decl.id?.type === "Identifier") { + outerVars.add(decl.id.name); + } + }); + } + } + } + } + current = current.parent; + } + + return outerVars; + } + + /** + * Checks if an identifier is being assigned/mutated. + * + * Detects: + * - Direct assignment: a = 5 + * - Compound assignment: a += 1, a -= 1, a *= 2, etc. + * - Increment/decrement: a++, ++a, a--, --a + * + * Does NOT flag reads: + * - return a; + * - const b = a + 1; + * - console.log(a); + * + * @param node - Identifier node to check + * @returns true if the identifier is being mutated + */ + function isAssignment(node: any): boolean { + const parent = node.parent; + if (!parent) return false; + + // Check for assignment expressions + // Examples: a = 5, a += 1, a -= 2, a *= 3 + if (parent.type === "AssignmentExpression" && parent.left === node) { + return true; + } + + // Check for update expressions + // Examples: a++, ++a, a--, --a + if (parent.type === "UpdateExpression" && parent.argument === node) { + return true; + } + + return false; + } + + /** + * Checks if an identifier usage is a problematic closure mutation. + * + * Reports an error if: + * 1. The identifier is being assigned/mutated (not just read) + * 2. The variable is NOT declared in the callback itself + * 3. The variable IS declared in an outer scope + * + * Example that triggers error: + * let counter = 0; // Outer scope + * await context.step(async () => { + * counter++; // ❌ Mutating outer variable + * }); + * + * Example that's allowed: + * let counter = 0; // Outer scope + * await context.step(async () => { + * return counter + 1; // ✅ Just reading + * }); + * + * @param node - Identifier node to check + * @param callback - The callback function containing this identifier + */ + function checkIdentifierUsage(node: any, callback: any) { + const declaredInCallback = getVariablesDeclaredInScope(callback); + const outerVars = findOuterScopeVariables(callback); + + if ( + node.type === "Identifier" && + !declaredInCallback.has(node.name) && // Not declared in callback + outerVars.has(node.name) && // Is from outer scope + isAssignment(node) // Is being mutated + ) { + context.report({ + node, + messageId: "closureVariableUsage", + data: { + variableName: node.name, + }, + }); + } + } + + // Main rule logic: analyze all function calls + return { + CallExpression(node: any) { + // Only check durable operations + if (!isDurableOperation(node)) return; + + // Get the callback function passed to the durable operation + const callback = getCallbackFunction(node); + if (!callback) return; + + // Get source code for AST traversal + const sourceCode = context.getSourceCode(); + + /** + * Recursively walks the callback's AST to find all identifier usages. + * + * For each identifier found, checks if it's a problematic closure mutation. + */ + function walkNode(n: any, cb: any) { + if (!n) return; + + // Check assignments and updates directly to avoid duplicate reports + if ( + n.type === "AssignmentExpression" && + n.left?.type === "Identifier" + ) { + checkIdentifierUsage(n.left, cb); + } else if ( + n.type === "UpdateExpression" && + n.argument?.type === "Identifier" + ) { + checkIdentifierUsage(n.argument, cb); + } + + // Recursively walk all child nodes + for (const key in n) { + if (key === "parent") continue; // Skip parent to avoid cycles + const child = n[key]; + if (Array.isArray(child)) { + child.forEach((c) => walkNode(c, cb)); + } else if (child && typeof child === "object") { + walkNode(child, cb); + } + } + } + + // Start walking from the callback body + walkNode(callback.body, callback); + }, + }; + }, +}; diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/__tests__/no-nested-durable-operations.test.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations/no-nested-durable-operations.test.ts similarity index 98% rename from packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/__tests__/no-nested-durable-operations.test.ts rename to packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations/no-nested-durable-operations.test.ts index 15fec73d..8119909f 100644 --- a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/__tests__/no-nested-durable-operations.test.ts +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations/no-nested-durable-operations.test.ts @@ -1,4 +1,4 @@ -import { noNestedDurableOperations } from "../no-nested-durable-operations"; +import { noNestedDurableOperations } from "./no-nested-durable-operations"; describe("no-nested-durable-operations", () => { it("should be defined", () => { diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations/no-nested-durable-operations.ts similarity index 100% rename from packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations.ts rename to packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-nested-durable-operations/no-nested-durable-operations.ts diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.integ.test.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.integ.test.ts new file mode 100644 index 00000000..6de038b2 --- /dev/null +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.integ.test.ts @@ -0,0 +1,198 @@ +import { RuleTester } from "eslint"; +import { noNonDeterministicOutsideStep } from "./no-non-deterministic-outside-step"; + +const ruleTester = new RuleTester({ + parser: require.resolve("@typescript-eslint/parser"), +} as any); + +describe("no-non-deterministic-outside-step integration tests", () => { + ruleTester.run( + "no-non-deterministic-outside-step", + noNonDeterministicOutsideStep, + { + valid: [ + // Non-deterministic operations inside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const result = await context.step(async () => { + return Math.random(); + }); + } + `, + }, + { + code: ` + async function handler(event: any, context: DurableContext) { + const result = await context.step(async () => { + return Date.now(); + }); + } + `, + }, + { + code: ` + async function handler(event: any, context: DurableContext) { + const result = await context.step(async () => { + return new Date(); + }); + } + `, + }, + { + code: ` + async function handler(event: any, context: DurableContext) { + const result = await context.step(async () => { + return performance.now(); + }); + } + `, + }, + { + code: ` + async function handler(event: any, context: DurableContext) { + const result = await context.step(async () => { + return crypto.randomBytes(16); + }); + } + `, + }, + // Deterministic operations outside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const value = 42; + const result = await context.step(async () => { + return value * 2; + }); + } + `, + }, + // Using Date with specific timestamp + { + code: ` + async function handler(event: any, context: DurableContext) { + const specificDate = new Date("2024-01-01"); + await context.step(async () => "done"); + } + `, + }, + ], + invalid: [ + // Math.random() outside step (reports both call and member expression) + { + code: ` + async function handler(event: any, context: DurableContext) { + const random = Math.random(); + await context.step(async () => random); + } + `, + errors: 2, // CallExpression + MemberExpression + }, + // Date.now() outside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const timestamp = Date.now(); + await context.step(async () => timestamp); + } + `, + errors: 2, // CallExpression + MemberExpression + }, + // new Date() without arguments outside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const now = new Date(); + await context.step(async () => now); + } + `, + errors: [ + { + messageId: "nonDeterministicOutsideStep", + data: { operation: "new Date()" }, + }, + ], + }, + // performance.now() outside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const time = performance.now(); + await context.step(async () => time); + } + `, + errors: 2, // CallExpression + MemberExpression + }, + // crypto.randomBytes() outside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const bytes = crypto.randomBytes(16); + await context.step(async () => bytes); + } + `, + errors: [ + { + messageId: "nonDeterministicOutsideStep", + data: { operation: "crypto.randomBytes()" }, + }, + ], + }, + // crypto.getRandomValues() outside step + { + code: ` + async function handler(event: any, context: DurableContext) { + const array = new Uint32Array(10); + crypto.getRandomValues(array); + await context.step(async () => array); + } + `, + errors: [ + { + messageId: "nonDeterministicOutsideStep", + data: { operation: "crypto.getRandomValues()" }, + }, + ], + }, + // Multiple non-deterministic operations + { + code: ` + async function handler(event: any, context: DurableContext) { + const random = Math.random(); + const timestamp = Date.now(); + await context.step(async () => random + timestamp); + } + `, + errors: 4, // 2 for Math.random + 2 for Date.now + }, + // Non-deterministic in runInChildContext (outside step) + { + code: ` + async function handler(event: any, context: DurableContext) { + await context.runInChildContext(async (ctx) => { + const random = Math.random(); + await ctx.step(async () => random); + }); + } + `, + errors: 2, // CallExpression + MemberExpression + }, + // Non-deterministic in parallel branch (outside step) + { + code: ` + async function handler(event: any, context: DurableContext) { + await context.parallel([ + async (ctx) => { + const random = Math.random(); + return ctx.step(async () => random); + }, + ]); + } + `, + errors: 2, // CallExpression + MemberExpression + }, + ], + }, + ); +}); diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/__tests__/no-non-deterministic-outside-step.test.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.test.ts similarity index 97% rename from packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/__tests__/no-non-deterministic-outside-step.test.ts rename to packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.test.ts index bf163755..1b26268a 100644 --- a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/__tests__/no-non-deterministic-outside-step.test.ts +++ b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.test.ts @@ -1,4 +1,4 @@ -import { noNonDeterministicOutsideStep } from "../no-non-deterministic-outside-step"; +import { noNonDeterministicOutsideStep } from "./no-non-deterministic-outside-step"; describe("no-non-deterministic-outside-step", () => { it("should be defined", () => { diff --git a/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step.ts b/packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.ts similarity index 100% rename from packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step.ts rename to packages/aws-durable-execution-sdk-js-eslint-plugin/src/rules/no-non-deterministic-outside-step/no-non-deterministic-outside-step.ts