diff --git a/.github/workflows/daily-byok-ollama-test.lock.yml b/.github/workflows/daily-byok-ollama-test.lock.yml index 857f15a3b9a..38ffad98ff2 100644 --- a/.github/workflows/daily-byok-ollama-test.lock.yml +++ b/.github/workflows/daily-byok-ollama-test.lock.yml @@ -1451,7 +1451,7 @@ jobs: printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}" - printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.43/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"targets\":{\"copilot\":{\"host\":\"host.docker.internal:11434\"}}},\"container\":{\"imageTag\":\"0.27.43,squid=sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d,agent=sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6,api-proxy=sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1,cli-proxy=sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.43/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"defaultAiCreditsPricing\":{\"input\":0.000001,\"output\":0.000001},\"targets\":{\"copilot\":{\"host\":\"host.docker.internal:11434\"}},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.43,squid=sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d,agent=sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6,api-proxy=sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1,cli-proxy=sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" GH_AW_DOCKER_HOST="" diff --git a/eslint-factory/src/rules/core-method-resolve.ts b/eslint-factory/src/rules/core-method-resolve.ts index e0020e03686..61161ab067e 100644 --- a/eslint-factory/src/rules/core-method-resolve.ts +++ b/eslint-factory/src/rules/core-method-resolve.ts @@ -1,18 +1,79 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; import { CORE_ALIASES } from "./core-aliases"; +/** + * Matches `@param {typeof import('@actions/core')} ` (single or double quotes) + * in a JSDoc block-comment value. Used as an unambiguous signal that a parameter + * is a dependency-injected `@actions/core`-like object. + */ +const JSDOC_CORE_PARAM_RE = /@param\s*\{typeof\s+import\(['"]@actions\/core['"]\)\}\s+([$\w]+)/g; + +/** + * Returns true when the enclosing function carries a JSDoc block comment with + * `@param {typeof import('@actions/core')} `. + * For function expressions / arrow functions assigned to a variable the JSDoc is + * typically before the VariableDeclaration, not the function node itself, so both + * positions are checked. + */ +function hasJSDocCoreParamAnnotation(functionNode: TSESTree.Node, paramName: string, sourceCode: TSESLint.SourceCode): boolean { + const nodesToCheck: TSESTree.Node[] = [functionNode]; + const parent = functionNode.parent; + if (parent?.type === AST_NODE_TYPES.VariableDeclarator && parent.parent) { + nodesToCheck.push(parent.parent); + } + // JSDoc before `export function` / `export async function` / `export default function` + // is attached to the ExportNamedDeclaration/ExportDefaultDeclaration, not the inner + // FunctionDeclaration/FunctionExpression, so check the export wrapper as well. + if (parent?.type === AST_NODE_TYPES.ExportNamedDeclaration || parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration) { + nodesToCheck.push(parent); + } + for (const node of nodesToCheck) { + for (const comment of sourceCode.getCommentsBefore(node)) { + if (comment.type !== "Block" || !comment.value.startsWith("*")) continue; + JSDOC_CORE_PARAM_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = JSDOC_CORE_PARAM_RE.exec(comment.value)) !== null) { + if (m[1] === paramName) return true; + } + } + } + return false; +} + +/** + * Returns true when `identifier` resolves (via scope chain) to a function parameter + * that carries a JSDoc `@param {typeof import('@actions/core')}` annotation. + * Used as the slow-path for DI parameters not covered by CORE_ALIASES. + */ +function isJSDocCoreParamInScope(identifier: TSESTree.Identifier, sourceCode: TSESLint.SourceCode): boolean { + let currentScope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier); + while (currentScope !== null) { + const variable = currentScope.set.get(identifier.name); + if (variable !== undefined) { + if (variable.defs.length !== 1) return false; + const def = variable.defs[0]; + if (def.type !== "Parameter" || def.name.type !== AST_NODE_TYPES.Identifier) return false; + return hasJSDocCoreParamAnnotation(def.node, identifier.name, sourceCode); + } + currentScope = currentScope.upper; + } + return false; +} + /** * Checks whether an Identifier is a single-assignment alias for a core-like * object (e.g., `const c = core`). Re-assigned let bindings are rejected. * Local shadows (e.g., a parameter also named `c`) are excluded because they * are found first in the scope chain and their definition type will not match. * - * Additionally, a plain function parameter whose name is in CORE_ALIASES is - * accepted to support the dependency-injection pattern: + * Additionally, a plain function parameter is accepted to support the + * dependency-injection pattern: * `async function f(core) { core.setFailed(msg); }` - * Gating: only parameters whose name exactly matches a known alias (i.e. is in - * CORE_ALIASES) are accepted, preventing arbitrary parameters from being treated - * as core. Destructured parameters (e.g. `{ core }`) are excluded. + * Two gating strategies prevent arbitrary parameters from being treated as core: + * 1. Fast path — parameter name is in CORE_ALIASES (exact known aliases). + * 2. Slow path — enclosing function has a JSDoc `@param {typeof import('@actions/core')}` + * annotation for this parameter (strong, unambiguous signal). + * Destructured parameters (e.g. `{ core }`) are excluded in both paths. */ export function isCoreAliasIdentifier(identifier: TSESTree.Identifier, sourceCode: TSESLint.SourceCode): boolean { let currentScope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier); @@ -22,21 +83,20 @@ export function isCoreAliasIdentifier(identifier: TSESTree.Identifier, sourceCod if (variable.defs.length !== 1) return false; const def = variable.defs[0]; if (def.type === "Parameter") { - // Accept a plain function-parameter whose name is a known core alias. - // This covers the DI pattern: `function f(core) { core.setFailed(...) }`. - // For Parameter defs, `def.node` is the enclosing function node; `def.name` - // is the binding pattern (Identifier for simple params, ObjectPattern for - // destructured params). We gate on `def.name.type === Identifier` to exclude - // destructured parameters, and on `CORE_ALIASES` to avoid false positives. - // `identifier.name` equals the parameter name because the scope look-up - // above resolved the variable by that name. - return def.name.type === AST_NODE_TYPES.Identifier && CORE_ALIASES.has(identifier.name); + // Only plain (non-destructured) parameters are accepted. + if (def.name.type !== AST_NODE_TYPES.Identifier) return false; + // Fast path: known exact alias names (e.g. `core`, `coreObj`). + if (CORE_ALIASES.has(identifier.name)) return true; + // Slow path: JSDoc @param {typeof import('@actions/core')} annotation on + // the enclosing function is an unambiguous signal for DI-style parameters + // with non-canonical names (e.g. `coreArg`, `coreLib`). + return hasJSDocCoreParamAnnotation(def.node, identifier.name, sourceCode); } if (def.type !== "Variable") return false; if (variable.references.some(ref => ref.isWrite() && !ref.init)) return false; const declarator = def.node as TSESTree.VariableDeclarator; if (!declarator.init) return false; - return declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init.type === AST_NODE_TYPES.Identifier && CORE_ALIASES.has(declarator.init.name); + return declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init.type === AST_NODE_TYPES.Identifier && (CORE_ALIASES.has(declarator.init.name) || isJSDocCoreParamInScope(declarator.init as TSESTree.Identifier, sourceCode)); } currentScope = currentScope.upper; } @@ -61,7 +121,7 @@ export function isDestructuredCoreMethodIdentifier(identifier: TSESTree.Identifi if (variable.references.some(ref => ref.isWrite() && !ref.init)) return false; const declarator = def.node as TSESTree.VariableDeclarator; if (!declarator.init) return false; - if (declarator.id.type === AST_NODE_TYPES.ObjectPattern && declarator.init.type === AST_NODE_TYPES.Identifier && CORE_ALIASES.has(declarator.init.name)) { + if (declarator.id.type === AST_NODE_TYPES.ObjectPattern && declarator.init.type === AST_NODE_TYPES.Identifier && (CORE_ALIASES.has(declarator.init.name) || isJSDocCoreParamInScope(declarator.init, sourceCode))) { return declarator.id.properties.some(prop => { if (prop.type !== AST_NODE_TYPES.Property || prop.computed) return false; const keyIsMethod = prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === methodName; diff --git a/eslint-factory/src/rules/no-core-setoutput-non-string.test.ts b/eslint-factory/src/rules/no-core-setoutput-non-string.test.ts index d6ee7a16ef0..756a1fc7585 100644 --- a/eslint-factory/src/rules/no-core-setoutput-non-string.test.ts +++ b/eslint-factory/src/rules/no-core-setoutput-non-string.test.ts @@ -9,6 +9,13 @@ const cjsRuleTester = new RuleTester({ }, }); +const esmRuleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, +}); + describe("no-core-setoutput-non-string", () => { it("uses the correct docs URL", () => { expect(noCoreSetOutputNonStringRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#no-core-setoutput-non-string"); @@ -364,4 +371,138 @@ describe("no-core-setoutput-non-string", () => { invalid: [], }); }); + + it("valid: JSDoc-annotated DI parameter with string value is accepted", () => { + cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, { + valid: [ + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { coreArg.setOutput("n", "str"); }`, + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { coreArg.setOutput("n", someVariable); }`, + // coreLib as a differently-named DI parameter + `/** @param {typeof import('@actions/core')} coreLib */ +function f(coreLib) { coreLib.setOutput("n", String(count)); }`, + // double-quote variant + `/** @param {typeof import("@actions/core")} coreArg */ +function f(coreArg) { coreArg.setOutput("n", "str"); }`, + // un-annotated parameter must NOT be treated as core + `function f(coreArg) { coreArg.setOutput("n", 0); }`, + ], + invalid: [], + }); + }); + + it("valid: export async function with JSDoc-annotated DI parameter is accepted (ESM)", () => { + esmRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, { + valid: [ + // export function — JSDoc is before the ExportNamedDeclaration, not the inner FunctionDeclaration + `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setOutput("n", "str"); }`, + ], + invalid: [], + }); + }); + + it("invalid: JSDoc-annotated DI parameter with non-string value is flagged", () => { + cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, { + valid: [], + invalid: [ + { + code: `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { coreArg.setOutput("count", 0); }`, + errors: [ + { + messageId: "nonStringValue", + data: { kind: "numeric literal", valueText: "0" }, + suggestions: [ + { + messageId: "wrapWithString", + data: { valueText: "0" }, + output: `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { coreArg.setOutput("count", String(0)); }`, + }, + ], + }, + ], + }, + { + code: `/** @param {typeof import('@actions/core')} coreLib */ +function g(coreLib) { coreLib.setOutput("flag", true); }`, + errors: [ + { + messageId: "nonStringValue", + suggestions: [ + { + messageId: "wrapWithString", + output: `/** @param {typeof import('@actions/core')} coreLib */ +function g(coreLib) { coreLib.setOutput("flag", String(true)); }`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: export async function with JSDoc-annotated DI parameter with non-string value is flagged (ESM)", () => { + esmRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, { + valid: [], + invalid: [ + // export async function — JSDoc is before the ExportNamedDeclaration + { + code: `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setOutput("count", 0); }`, + errors: [ + { + messageId: "nonStringValue", + suggestions: [ + { + messageId: "wrapWithString", + output: `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setOutput("count", String(0)); }`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("valid: JSDoc-annotated DI param destructuring with string value is accepted", () => { + cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, { + valid: [ + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setOutput } = coreArg; setOutput("n", "str"); }`, + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setOutput } = coreArg; setOutput("n", someVariable); }`, + ], + invalid: [], + }); + }); + + it("invalid: JSDoc-annotated DI param destructuring with non-string value is flagged", () => { + cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, { + valid: [], + invalid: [ + { + code: `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setOutput } = coreArg; setOutput("count", 0); }`, + errors: [ + { + messageId: "nonStringValue", + suggestions: [ + { + messageId: "wrapWithString", + output: `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setOutput } = coreArg; setOutput("count", String(0)); }`, + }, + ], + }, + ], + }, + ], + }); + }); }); diff --git a/eslint-factory/src/rules/require-return-after-core-setfailed.test.ts b/eslint-factory/src/rules/require-return-after-core-setfailed.test.ts index 22aefa1d0bb..a507744a9a4 100644 --- a/eslint-factory/src/rules/require-return-after-core-setfailed.test.ts +++ b/eslint-factory/src/rules/require-return-after-core-setfailed.test.ts @@ -9,6 +9,13 @@ const ruleTester = new RuleTester({ }, }); +const esmRuleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, +}); + describe("require-return-after-core-setfailed", () => { it("uses the correct docs URL", () => { expect(requireReturnAfterCoreSetFailedRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#require-return-after-core-setfailed"); @@ -390,6 +397,154 @@ doMore();`, }); }); + it("valid: JSDoc-annotated DI parameter with control transfer is accepted", () => { + ruleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { + valid: [ + // Corpus case: coreArg annotated with typeof import('@actions/core'), throw follows + `/** @param {typeof import('@actions/core')} coreArg */ +async function validateContextVariables(coreArg, ctx) { + coreArg.setFailed("bad"); + throw new Error("bad"); +}`, + // return follows setFailed + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { coreArg.setFailed("x"); return; }`, + // setFailed is the last statement in the block — nothing to continue into + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { coreArg.setFailed("x"); }`, + // coreLib as a differently-named parameter + `/** @param {typeof import('@actions/core')} coreLib */ +function f(coreLib) { coreLib.setFailed("x"); return; }`, + // double-quote variant of the JSDoc type annotation + `/** @param {typeof import("@actions/core")} coreArg */ +function f(coreArg) { coreArg.setFailed("x"); return; }`, + // un-annotated parameter with the same name must NOT be treated as core + // (the rule should not fire because coreArg is not recognised as core) + `function f(coreArg) { coreArg.setFailed("x"); doMore(); }`, + ], + invalid: [], + }); + }); + + it("invalid: JSDoc-annotated DI parameter missing control transfer is flagged", () => { + ruleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { + valid: [], + invalid: [ + { + code: `/** @param {typeof import('@actions/core')} coreArg */ +async function f(coreArg, ctx) { coreArg.setFailed("bad"); doMore(); keepGoing(); }`, + errors: [ + { + messageId: "missingReturnAfterSetFailed", + suggestions: [ + { + messageId: "addReturn", + output: `/** @param {typeof import('@actions/core')} coreArg */ +async function f(coreArg, ctx) { coreArg.setFailed("bad"); return; doMore(); keepGoing(); }`, + }, + ], + }, + ], + }, + { + code: `/** @param {typeof import('@actions/core')} coreLib */ +function g(coreLib) { coreLib.setFailed("x"); doMore(); keepGoing(); }`, + errors: [ + { + messageId: "missingReturnAfterSetFailed", + suggestions: [ + { + messageId: "addReturn", + output: `/** @param {typeof import('@actions/core')} coreLib */ +function g(coreLib) { coreLib.setFailed("x"); return; doMore(); keepGoing(); }`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("valid: export async function with JSDoc-annotated DI parameter is accepted (ESM)", () => { + esmRuleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { + valid: [ + // export async function — JSDoc is before the ExportNamedDeclaration, not the inner FunctionDeclaration + `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setFailed("x"); return; }`, + `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setFailed("x"); throw new Error("x"); }`, + ], + invalid: [], + }); + }); + + it("invalid: export async function with JSDoc-annotated DI parameter missing control transfer is flagged (ESM)", () => { + esmRuleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { + valid: [], + invalid: [ + // export async function — JSDoc is before the ExportNamedDeclaration + { + code: `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setFailed("bad"); doMore(); keepGoing(); }`, + errors: [ + { + messageId: "missingReturnAfterSetFailed", + suggestions: [ + { + messageId: "addReturn", + output: `/** @param {typeof import('@actions/core')} coreArg */ +export async function main(coreArg) { coreArg.setFailed("bad"); return; doMore(); keepGoing(); }`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("valid: JSDoc-annotated DI param destructuring with control transfer is accepted", () => { + ruleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { + valid: [ + // Destructured setFailed from JSDoc-annotated coreArg with throw + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setFailed } = coreArg; setFailed("x"); throw new Error("x"); }`, + // Destructured setFailed with return + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setFailed } = coreArg; setFailed("x"); return; }`, + // Destructured setFailed as last statement in block + `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setFailed } = coreArg; setFailed("x"); }`, + ], + invalid: [], + }); + }); + + it("invalid: JSDoc-annotated DI param destructuring missing control transfer is flagged", () => { + ruleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { + valid: [], + invalid: [ + { + code: `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setFailed } = coreArg; setFailed("bad"); doMore(); keepGoing(); }`, + errors: [ + { + messageId: "missingReturnAfterSetFailed", + suggestions: [ + { + messageId: "addReturn", + output: `/** @param {typeof import('@actions/core')} coreArg */ +function f(coreArg) { const { setFailed } = coreArg; setFailed("bad"); return; doMore(); keepGoing(); }`, + }, + ], + }, + ], + }, + ], + }); + }); + it("invalid: bare switch-case fall-through after setFailed is flagged (FN fix)", () => { ruleTester.run("require-return-after-core-setfailed", requireReturnAfterCoreSetFailedRule, { valid: [