diff --git a/eslint-factory/eslint.config.cjs b/eslint-factory/eslint.config.cjs index bda97c1cf83..9d6a23344fc 100644 --- a/eslint-factory/eslint.config.cjs +++ b/eslint-factory/eslint.config.cjs @@ -21,6 +21,7 @@ module.exports = [ "gh-aw-custom/require-async-entrypoint-catch": "warn", "gh-aw-custom/require-await-core-summary-write": "warn", "gh-aw-custom/require-error-cause-in-rethrow": "warn", + "gh-aw-custom/require-fs-sync-try-catch": "warn", "gh-aw-custom/require-json-parse-try-catch": "warn", "gh-aw-custom/require-parseInt-radix": "warn", }, diff --git a/eslint-factory/src/index.ts b/eslint-factory/src/index.ts index fe4e031ca3a..ad3cd1335a0 100644 --- a/eslint-factory/src/index.ts +++ b/eslint-factory/src/index.ts @@ -6,6 +6,7 @@ import { preferGetErrorMessageRule } from "./rules/prefer-get-error-message"; import { preferNumberIsNanRule } from "./rules/prefer-number-isnan"; import { requireAsyncEntrypointCatchRule } from "./rules/require-async-entrypoint-catch"; import { requireAwaitCoreSummaryWriteRule } from "./rules/require-await-core-summary-write"; +import { requireFsSyncTryCatchRule } from "./rules/require-fs-sync-try-catch"; import { requireJsonParseTryCatchRule } from "./rules/require-json-parse-try-catch"; import { requireErrorCauseInRethrowRule } from "./rules/require-error-cause-in-rethrow"; import { requireParseIntRadixRule } from "./rules/require-parseInt-radix"; @@ -25,6 +26,7 @@ const plugin = { "require-async-entrypoint-catch": requireAsyncEntrypointCatchRule, "require-await-core-summary-write": requireAwaitCoreSummaryWriteRule, "require-error-cause-in-rethrow": requireErrorCauseInRethrowRule, + "require-fs-sync-try-catch": requireFsSyncTryCatchRule, "require-json-parse-try-catch": requireJsonParseTryCatchRule, "require-parseInt-radix": requireParseIntRadixRule, }, diff --git a/eslint-factory/src/rules/require-fs-sync-try-catch.test.ts b/eslint-factory/src/rules/require-fs-sync-try-catch.test.ts new file mode 100644 index 00000000000..f8f17afbfc9 --- /dev/null +++ b/eslint-factory/src/rules/require-fs-sync-try-catch.test.ts @@ -0,0 +1,318 @@ +import { RuleTester } from "eslint"; +import { describe, it } from "vitest"; +import { requireFsSyncTryCatchRule } from "./require-fs-sync-try-catch"; + +const cjsRuleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "commonjs", + }, +}); + +const esmRuleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, +}); + +describe("require-fs-sync-try-catch", () => { + it("valid: fs.readFileSync inside try block passes (CommonJS)", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [ + `try { const x = fs.readFileSync(path, "utf8"); } catch (e) {}`, + `try { return fs.readFileSync(path, "utf8"); } catch (e) {}`, + `function f() { try { fs.readFileSync(path); } catch (e) {} }`, + `try { const x = fs["readFileSync"](path, "utf8"); } catch (e) {}`, + ], + invalid: [], + }); + }); + + it("valid: fs.writeFileSync and fs.appendFileSync inside try block pass", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [`try { fs.writeFileSync(path, data); } catch (e) {}`, `try { fs.appendFileSync(path, data); } catch (e) {}`, `try { fs["writeFileSync"](path, data); } catch (e) {}`], + invalid: [], + }); + }); + + it("valid: other fs methods not in scope are ignored", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [ + `fs.existsSync(path);`, + `fs.mkdirSync(dir, { recursive: true });`, + `fs.unlinkSync(path);`, + `fs.statSync(path);`, + `fs.readdirSync(dir);`, + `fs.rmSync(dir, { recursive: true });`, + // Non-fs objects with same method names are ignored + `mockFs.readFileSync(path);`, + `storage.writeFileSync(path, data);`, + ], + invalid: [], + }); + }); + + it("valid: destructured fs bindings stay out of scope", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [`const { readFileSync } = require("fs"); readFileSync(path, "utf8");`], + invalid: [], + }); + }); + + it("valid: fs inside try block (ES module) passes", () => { + esmRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [`try { const x = fs.readFileSync(path, "utf8"); } catch (e) {}`], + invalid: [], + }); + }); + + it("valid: synchronous callbacks inside try block are protected", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [ + // Array map is synchronous — try block is genuinely protective + `try { const results = paths.map(p => fs.readFileSync(p, "utf8")); } catch (e) {}`, + `try { items.forEach(p => { fs.writeFileSync(p, data); }); } catch (e) {}`, + // Locally-defined nextTick can be synchronous, so the surrounding try is protective + `try { const nextTick = fn => fn(); nextTick(() => { fs.readFileSync(path, "utf8"); }); } catch (e) {}`, + ], + invalid: [], + }); + }); + + it("invalid: bare fs.readFileSync is flagged with correct message and suggestion", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + { + code: `const content = fs.readFileSync(filePath, "utf8");`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "filePath" }, + suggestions: [], + }, + ], + }, + { + code: `fs.readFileSync(configPath, "utf8");`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "configPath" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try {\n fs.readFileSync(configPath, "utf8");\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: bare fs.writeFileSync is flagged", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + { + code: `fs.writeFileSync(outputPath, JSON.stringify(data));`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "writeFileSync", arg: "outputPath" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try {\n fs.writeFileSync(outputPath, JSON.stringify(data));\n} catch (err) {\n // TODO: handle I/O failure for this fs.writeFileSync call.\n throw new Error(\n "fs.writeFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: bare fs.appendFileSync is flagged", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + { + code: `fs.appendFileSync(logPath, line + "\\n");`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "appendFileSync", arg: "logPath" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try {\n fs.appendFileSync(logPath, line + "\\n");\n} catch (err) {\n // TODO: handle I/O failure for this fs.appendFileSync call.\n throw new Error(\n "fs.appendFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`, + }, + ], + }, + ], + }, + ], + }); + }); + + it('invalid: computed fs["readFileSync"] access is flagged when not in try block', () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + { + code: `const data = fs["readFileSync"](path, "utf8");`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "path" }, + suggestions: [], + }, + ], + }, + ], + }); + }); + + it("invalid: fs.readFileSync in deferred callback is not protected by surrounding try", () => { + cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + // EventEmitter .on — callback fires asynchronously + { + code: `try { emitter.on("data", () => { fs.readFileSync(path, "utf8"); }); } catch (e) {}`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "path" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try { emitter.on("data", () => { try {\n fs.readFileSync(path, "utf8");\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n} }); } catch (e) {}`, + }, + ], + }, + ], + }, + // setTimeout — callback fires asynchronously + { + code: `try { setTimeout(() => { fs.writeFileSync(p, d); }, 0); } catch (e) {}`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "writeFileSync", arg: "p" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try { setTimeout(() => { try {\n fs.writeFileSync(p, d);\n} catch (err) {\n // TODO: handle I/O failure for this fs.writeFileSync call.\n throw new Error(\n "fs.writeFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n} }, 0); } catch (e) {}`, + }, + ], + }, + ], + }, + // process.nextTick — callback runs after the surrounding try has returned + { + code: `try { process.nextTick(() => { fs.readFileSync(path, "utf8"); }); } catch (e) {}`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "path" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try { process.nextTick(() => { try {\n fs.readFileSync(path, "utf8");\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n} }); } catch (e) {}`, + }, + ], + }, + ], + }, + // async function bodies are still synchronous relative to their own frame + { + code: `async function load() { fs.readFileSync(path, "utf8"); }`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "path" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `async function load() { try {\n fs.readFileSync(path, "utf8");\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n} }`, + }, + ], + }, + ], + }, + // new Promise executor — Promise captures throws + { + code: `try { new Promise(resolve => { fs.readFileSync(path); }); } catch (e) {}`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "path" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try { new Promise(resolve => { try {\n fs.readFileSync(path);\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n} }); } catch (e) {}`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: fs.readFileSync inside if-branch without surrounding try is flagged (ES module)", () => { + esmRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + { + code: `if (cond) {\n const raw = fs.readFileSync(p, "utf8");\n}`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "p" }, + suggestions: [], + }, + ], + }, + ], + }); + }); + + it("invalid: unsupported positions and multi-line expressions handle suggestions safely", () => { + esmRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, { + valid: [], + invalid: [ + { + code: `export default fs.readFileSync(config, "utf8");`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "config" }, + suggestions: [], + }, + ], + }, + { + code: `fs.readFileSync(\n config,\n "utf8",\n);`, + errors: [ + { + messageId: "requireTryCatch", + data: { method: "readFileSync", arg: "config" }, + suggestions: [ + { + messageId: "wrapInTryCatch", + output: `try {\n fs.readFileSync(\n config,\n "utf8",\n );\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`, + }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/eslint-factory/src/rules/require-fs-sync-try-catch.ts b/eslint-factory/src/rules/require-fs-sync-try-catch.ts new file mode 100644 index 00000000000..fc6f170acf6 --- /dev/null +++ b/eslint-factory/src/rules/require-fs-sync-try-catch.ts @@ -0,0 +1,124 @@ +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { buildTryCatchSuggestion, isDeferredCallback, SAFE_WRAPPABLE_STATEMENT_TYPES } from "./try-catch-rule-utils"; + +const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); + +// fs module methods that throw on I/O failure and are in scope for this rule. +// readFileSync / writeFileSync / appendFileSync are the highest-frequency, highest-risk callers +// in actions/setup/js. Other sync methods (mkdirSync, unlinkSync, …) are left out of scope for +// now to keep FP risk low on the first iteration. +const FS_SYNC_METHODS = new Set(["readFileSync", "writeFileSync", "appendFileSync"]); + +export const requireFsSyncTryCatchRule = createRule({ + name: "require-fs-sync-try-catch", + meta: { + type: "problem", + hasSuggestions: true, + docs: { + description: + "Require fs.readFileSync, fs.writeFileSync, and fs.appendFileSync calls in actions/setup/js scripts to be wrapped in try/catch. " + + "These methods throw synchronously on missing files, permission errors, and disk failures; " + + "an unhandled throw crashes the action without surfacing a useful error message.", + }, + schema: [], + messages: { + requireTryCatch: "Wrap fs.{{method}}({{arg}}) in try/catch — synchronous fs methods throw on I/O errors " + "(missing file, permission denied, disk full) and will crash the action if unhandled.", + wrapInTryCatch: "Wrap in try { ... } catch { ... } and re-throw with { cause: err } to preserve context.", + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + function isInsideTryBlock(node: TSESTree.Node): boolean { + const ancestors = sourceCode.getAncestors(node); + // Walk from innermost ancestor outward. Stop marking a try as protective once a deferred + // callback boundary has been crossed (the callback fires after the try has returned). + let crossedDeferredBoundary = false; + + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + + if (isDeferredCallback(ancestor)) { + crossedDeferredBoundary = true; + } + + if (ancestor.type === "TryStatement" && !crossedDeferredBoundary) { + const block = ancestor.block; + if (node.range != null && block.range != null && node.range[0] >= block.range[0] && node.range[1] <= block.range[1]) { + return true; + } + } + } + + return false; + } + + function findEnclosingStatement(node: TSESTree.Node): TSESTree.Statement | null { + const ancestors = sourceCode.getAncestors(node); + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + if (SAFE_WRAPPABLE_STATEMENT_TYPES.has(ancestor.type)) { + return ancestor as TSESTree.Statement; + } + } + return null; + } + + return { + CallExpression(node) { + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression) return; + + // Object must be the `fs` identifier (the standard import alias in actions/setup/js). + // Aliased references (const r = fs.readFileSync; r(path)) are intentionally out of scope. + if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.object.name !== "fs") return; + + // Accept both direct property access (fs.readFileSync) and computed string-literal access + // (fs["readFileSync"]). Dynamic computed access (fs[varName]) is excluded. + const property = callee.property; + let methodName: string | null = null; + if (!callee.computed && property.type === AST_NODE_TYPES.Identifier && FS_SYNC_METHODS.has(property.name)) { + methodName = property.name; + } else if (callee.computed && property.type === AST_NODE_TYPES.Literal && typeof property.value === "string" && FS_SYNC_METHODS.has(property.value)) { + methodName = property.value; + } + + if (!methodName) return; + + if (isInsideTryBlock(node)) return; + + const argText = node.arguments.length > 0 ? sourceCode.getText(node.arguments[0]) : ""; + const method = methodName; + const stmt = findEnclosingStatement(node); + + context.report({ + node, + messageId: "requireTryCatch", + data: { method, arg: argText }, + suggest: stmt + ? [ + { + messageId: "wrapInTryCatch", + fix(fixer) { + const stmtText = sourceCode.getText(stmt); + const startLine = stmt.loc?.start.line; + const stmtLine = startLine !== undefined ? (sourceCode.lines[startLine - 1] ?? "") : ""; + const indent = stmtLine.match(/^(\s*)/)?.[1] ?? ""; + return fixer.replaceText( + stmt, + buildTryCatchSuggestion(stmtText, { + indent, + todoComment: `TODO: handle I/O failure for this fs.${method} call.`, + errorPrefix: `fs.${method} failed: `, + }) + ); + }, + }, + ] + : [], + }); + }, + }; + }, +}); diff --git a/eslint-factory/src/rules/require-json-parse-try-catch.ts b/eslint-factory/src/rules/require-json-parse-try-catch.ts index 200052cb0c0..59af7a10362 100644 --- a/eslint-factory/src/rules/require-json-parse-try-catch.ts +++ b/eslint-factory/src/rules/require-json-parse-try-catch.ts @@ -1,75 +1,10 @@ import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { buildTryCatchSuggestion, isDeferredCallback, SAFE_WRAPPABLE_STATEMENT_TYPES } from "./try-catch-rule-utils"; const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); // Statement node types that can be directly wrapped in a try/catch block. -const WRAPPABLE_STATEMENT_TYPES = new Set([AST_NODE_TYPES.ExpressionStatement, AST_NODE_TYPES.VariableDeclaration, AST_NODE_TYPES.ReturnStatement, AST_NODE_TYPES.ThrowStatement]); - -// Method/function names that accept callbacks whose throws are not protected by a try that -// encloses the call site. Most execute later (outside the dynamic extent of the surrounding try); -// Promise executors are synchronous, but Promise captures their throws instead of letting the -// outer try observe them. -const DEFERRED_SINK_NAMES = new Set([ - "then", - "catch", - "finally", // Promise methods - "on", - "once", // EventEmitter / Node streams - "addEventListener", // DOM / Node - "setTimeout", - "setInterval", - "setImmediate", - "queueMicrotask", - "nextTick", // process.nextTick -]); - -function isFunctionExpressionLike(node: TSESTree.Node): node is TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression { - return node.type === AST_NODE_TYPES.ArrowFunctionExpression || node.type === AST_NODE_TYPES.FunctionExpression; -} - -/** Returns true when funcNode is passed to a callback sink not protected by the outer try. */ -function isDeferredCallback(funcNode: TSESTree.Node): boolean { - if (!isFunctionExpressionLike(funcNode)) return false; - - const parent = funcNode.parent; - if (!parent) return false; - - const isCallLikeParent = parent.type === "NewExpression" || parent.type === "CallExpression"; - const args = isCallLikeParent ? parent.arguments : undefined; - const isArgument = args?.includes(funcNode) ?? false; - // Only direct new Promise(...) is in scope here; aliased constructors are intentionally ignored. - const isPromiseConstructor = parent.type === "NewExpression" && parent.callee.type === "Identifier" && parent.callee.name === "Promise"; - if (isPromiseConstructor && isArgument) { - return true; - } - - // obj.method(cb) or globalFn(cb) where the method/function is a known deferred sink - if (parent.type === "CallExpression" && isArgument) { - const callee = parent.callee; - if (callee.type === "Identifier" && DEFERRED_SINK_NAMES.has(callee.name)) { - return true; - } - if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") { - return DEFERRED_SINK_NAMES.has(callee.property.name); - } - } - - return false; -} - -function buildTryCatchSuggestion(stmtText: string, indent: string): string { - return [ - "try {", - `${indent} ${stmtText}`, - `${indent}} catch (err) {`, - `${indent} // TODO: handle parse failure for this code path.`, - `${indent} throw new Error(`, - `${indent} "Failed to parse JSON: " + (err instanceof Error ? err.message : String(err)),`, - `${indent} { cause: err },`, - `${indent} );`, - `${indent}}`, - ].join("\n"); -} +const WRAPPABLE_STATEMENT_TYPES = new Set([...SAFE_WRAPPABLE_STATEMENT_TYPES, AST_NODE_TYPES.VariableDeclaration, AST_NODE_TYPES.ThrowStatement]); export const requireJsonParseTryCatchRule = createRule({ name: "require-json-parse-try-catch", @@ -172,7 +107,14 @@ export const requireJsonParseTryCatchRule = createRule({ const startLine = stmt.loc?.start.line; const stmtLine = startLine !== undefined ? (sourceCode.lines[startLine - 1] ?? "") : ""; const indent = stmtLine.match(/^(\s*)/)?.[1] ?? ""; - return fixer.replaceText(stmt, buildTryCatchSuggestion(stmtText, indent)); + return fixer.replaceText( + stmt, + buildTryCatchSuggestion(stmtText, { + indent, + todoComment: "TODO: handle parse failure for this code path.", + errorPrefix: "Failed to parse JSON: ", + }) + ); }, }, ], diff --git a/eslint-factory/src/rules/try-catch-rule-utils.ts b/eslint-factory/src/rules/try-catch-rule-utils.ts new file mode 100644 index 00000000000..7dd63b46cd2 --- /dev/null +++ b/eslint-factory/src/rules/try-catch-rule-utils.ts @@ -0,0 +1,96 @@ +import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils"; + +/** Callback sinks that are safe to recognize as either bare function calls or member calls. */ +const DEFERRED_SINK_NAMES = new Set(["then", "catch", "finally", "on", "once", "addEventListener", "setTimeout", "setInterval", "setImmediate", "queueMicrotask"]); + +/** + * Callback sinks that must only be recognized as member calls. + * This avoids false negatives for user-defined synchronous helpers like `nextTick(fn)`. + */ +const MEMBER_ONLY_DEFERRED_SINK_NAMES = new Set(["nextTick"]); + +export const SAFE_WRAPPABLE_STATEMENT_TYPES = new Set([AST_NODE_TYPES.ExpressionStatement, AST_NODE_TYPES.ReturnStatement]); + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getCommonContinuationIndent(lines: string[]): string { + const indents = lines.filter(line => line.trim().length > 0).map(line => line.match(/^(\s*)/)?.[1] ?? ""); + + if (indents.length === 0) return ""; + + let commonIndent = indents[0]; + for (const indent of indents.slice(1)) { + let shared = 0; + while (shared < commonIndent.length && shared < indent.length && commonIndent[shared] === indent[shared]) { + shared++; + } + commonIndent = commonIndent.slice(0, shared); + } + + return commonIndent; +} + +function isFunctionExpressionLike(node: TSESTree.Node): node is TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression { + return node.type === AST_NODE_TYPES.ArrowFunctionExpression || node.type === AST_NODE_TYPES.FunctionExpression; +} + +/** Returns true when funcNode is passed to a callback sink not protected by the outer try. */ +export function isDeferredCallback(funcNode: TSESTree.Node): boolean { + if (!isFunctionExpressionLike(funcNode)) return false; + + const parent = funcNode.parent; + if (!parent) return false; + + const isCallLikeParent = parent.type === AST_NODE_TYPES.NewExpression || parent.type === AST_NODE_TYPES.CallExpression; + const args = isCallLikeParent ? parent.arguments : undefined; + const isArgument = args?.includes(funcNode) ?? false; + const isPromiseConstructor = parent.type === AST_NODE_TYPES.NewExpression && parent.callee.type === AST_NODE_TYPES.Identifier && parent.callee.name === "Promise"; + if (isPromiseConstructor && isArgument) { + return true; + } + + if (parent.type === AST_NODE_TYPES.CallExpression && isArgument) { + const callee = parent.callee; + if (callee.type === AST_NODE_TYPES.Identifier && DEFERRED_SINK_NAMES.has(callee.name)) { + return true; + } + if (callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES.Identifier) { + return DEFERRED_SINK_NAMES.has(callee.property.name) || MEMBER_ONLY_DEFERRED_SINK_NAMES.has(callee.property.name); + } + } + + return false; +} + +type TryCatchSuggestionOptions = { + indent: string; + todoComment: string; + errorPrefix: string; +}; + +export function buildTryCatchSuggestion(stmtText: string, options: TryCatchSuggestionOptions): string { + const { indent, todoComment, errorPrefix } = options; + const lines = stmtText.split("\n"); + const continuationIndent = getCommonContinuationIndent(lines.slice(1)); + const continuationIndentPattern = continuationIndent.length > 0 ? new RegExp(`^${escapeRegex(continuationIndent)}`) : null; + const indentedStatement = lines + .map((line, index) => { + const normalizedLine = index === 0 ? line.trimStart() : continuationIndentPattern ? line.replace(continuationIndentPattern, "") : line; + return `${indent} ${normalizedLine}`; + }) + .join("\n"); + + return [ + "try {", + indentedStatement, + `${indent}} catch (err) {`, + `${indent} // ${todoComment}`, + `${indent} throw new Error(`, + `${indent} "${errorPrefix}" + (err instanceof Error ? err.message : String(err)),`, + `${indent} { cause: err },`, + `${indent} );`, + `${indent}}`, + ].join("\n"); +}