-
Notifications
You must be signed in to change notification settings - Fork 475
eslint-factory: flag core.error + core.setFailed(prefix + msg) as redundant #49496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
119c4a3
efa3729
2e7ecf3
286f850
bf40d45
69163ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,17 +28,8 @@ describe("no-core-error-then-setfailed", () => { | |
| `core.error("msg"); doSomething(); core.setFailed("msg");`, | ||
| // Different messages — core.error provides extra context not repeated by setFailed | ||
| `core.error("upload failed: " + filename); core.setFailed("action failed");`, | ||
| // Different template literals — messages differ in prefix | ||
| { | ||
| code: ` | ||
| try { | ||
| doSomething(); | ||
| } catch (err) { | ||
| core.error(\`Failed: \${err.message}\`); | ||
| core.setFailed(\`ERR: Failed: \${err.message}\`); | ||
| } | ||
| `, | ||
| }, | ||
| // Suffix added to setFailed message — not a prefix-match shape, not flagged | ||
| "core.error(`Failed: ${msg}`); core.setFailed(`Failed: ${msg} [extra context]`);", | ||
| // core.error with annotation properties — carries extra diagnostic context | ||
| `core.error("msg", { title: "Upload error" }); core.setFailed("msg");`, | ||
| // Different core objects (cross-alias false-positive guard): | ||
|
|
@@ -88,6 +79,46 @@ describe("no-core-error-then-setfailed", () => { | |
| }, | ||
| ], | ||
| }, | ||
| // Literal prefix in setFailed template literal — same message with error-code text prefix | ||
| { | ||
| code: "core.error(`Failed: ${err.message}`); core.setFailed(`ERR: Failed: ${err.message}`);", | ||
| errors: [ | ||
| { | ||
| messageId: "noCoreErrorThenSetFailed", | ||
| suggestions: [{ messageId: "removeErrorCall", output: " core.setFailed(`ERR: Failed: ${err.message}`);" }], | ||
| }, | ||
| ], | ||
| }, | ||
| // Expression prefix in setFailed template literal — mirrors add_reaction.cjs:184-185 | ||
| { | ||
| code: "core.error(`Failed to add reaction: ${errorMessage}`); core.setFailed(`${ERR_API}: Failed to add reaction: ${errorMessage}`);", | ||
| errors: [ | ||
| { | ||
| messageId: "noCoreErrorThenSetFailed", | ||
| suggestions: [{ messageId: "removeErrorCall", output: " core.setFailed(`${ERR_API}: Failed to add reaction: ${errorMessage}`);" }], | ||
| }, | ||
| ], | ||
| }, | ||
| // Expression-first (empty leading quasi) in errorArg — errorArg starts with an expression | ||
| { | ||
| code: "core.error(`${msg} failed`); core.setFailed(`ERR: ${msg} failed`);", | ||
| errors: [ | ||
| { | ||
| messageId: "noCoreErrorThenSetFailed", | ||
| suggestions: [{ messageId: "removeErrorCall", output: " core.setFailed(`ERR: ${msg} failed`);" }], | ||
| }, | ||
| ], | ||
| }, | ||
| // BinaryExpression concatenation: "prefix" + templateLiteral matching errorArg | ||
| { | ||
| code: 'core.error(`Failed: ${msg}`); core.setFailed("ERR: " + `Failed: ${msg}`);', | ||
| errors: [ | ||
| { | ||
| messageId: "noCoreErrorThenSetFailed", | ||
| suggestions: [{ messageId: "removeErrorCall", output: ' core.setFailed("ERR: " + `Failed: ${msg}`);' }], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Missing edge-case: 💡 Suggested test case// Expression-first error arg with expression prefix in setFailed
{
code: 'core.error(`${msg}`); core.setFailed(`ERR: ${msg}`);',
errors: [{ messageId: 'noCoreErrorThenSetFailed', ... }],
},This exercises the empty-leading-quasi path and confirms that @copilot please address this. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,88 @@ function hasAnnotationProperties(call: TSESTree.CallExpression): boolean { | |
| return call.arguments.length > 1; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when `setFailedArg` is a TemplateLiteral whose content ends | ||
| * with the full content of `errorArg` (also a TemplateLiteral), with a | ||
| * non-empty prefix prepended. The prefix may contain template expressions. | ||
| * This captures the common pattern: | ||
| * core.error(`Failed: ${msg}`) | ||
| * core.setFailed(`${ERR_CODE}: Failed: ${msg}`) | ||
| * where setFailed emits the same annotation text with only an error-code | ||
| * prefix added in front. | ||
| * | ||
| * A match requires: | ||
| * 1. Both args are TemplateLiterals. | ||
| * 2. setFailed has at least as many expressions as error. | ||
| * 3. The last N expressions of setFailed match error's expressions in | ||
| * order (same source text), where N = error.expressions.length. | ||
| * 4. The quasis of setFailed at offsets [P+1 .. end] (P = prefix expr | ||
| * count) equal error's quasis at [1 .. end] (same cooked value). | ||
| * 5. The "junction" quasi in setFailed (at offset P) ends with error's | ||
| * first quasi (cooked value). | ||
| * 6. The prefix is non-empty (to avoid re-matching exact-equal pairs). | ||
| */ | ||
| function isSetFailedArgPrefixedVersion(errorArg: TSESTree.Expression, setFailedArg: TSESTree.Expression, sourceCode: SourceCode): boolean { | ||
| if (errorArg.type !== AST_NODE_TYPES.TemplateLiteral) return false; | ||
| if (setFailedArg.type !== AST_NODE_TYPES.TemplateLiteral) return false; | ||
|
Comment on lines
+55
to
+57
|
||
|
|
||
| const errTpl = errorArg as TSESTree.TemplateLiteral; | ||
| const sfTpl = setFailedArg as TSESTree.TemplateLiteral; | ||
|
|
||
| if (sfTpl.expressions.length < errTpl.expressions.length) return false; | ||
|
|
||
| const prefixExprCount = sfTpl.expressions.length - errTpl.expressions.length; | ||
|
|
||
| // Last errTpl.expressions.length expressions of sfTpl must match errTpl's expressions. | ||
| for (let i = 0; i < errTpl.expressions.length; i++) { | ||
| const sfExpr = sfTpl.expressions[prefixExprCount + i] as TSESTree.Expression; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expression-prefix matching relies purely on textual source comparison, which can be fooled by non-obvious cases. 💡 DetailsBoth Textual equality does not guarantee semantic equality — e.g. a shadowed Suggested hardening: since this is a suggestion (not autofix), reviewers already vet it manually, so the risk is moderate rather than critical. Still worth documenting this limitation explicitly in the function doc comment so future maintainers don't assume textual equality implies semantic equality. |
||
| const errExpr = errTpl.expressions[i] as TSESTree.Expression; | ||
| if (sourceCode.getText(sfExpr) !== sourceCode.getText(errExpr)) return false; | ||
| } | ||
|
|
||
| // Quasis at offsets [prefixExprCount+1 .. end] of sfTpl must equal errTpl quasis [1 .. end]. | ||
| // Guard against null cooked values (produced by invalid Unicode escape sequences). | ||
| for (let i = 1; i < errTpl.quasis.length; i++) { | ||
| const sfCooked = sfTpl.quasis[prefixExprCount + i].value.cooked; | ||
| const errCooked = errTpl.quasis[i].value.cooked; | ||
| if (sfCooked === null || errCooked === null) return false; | ||
| if (sfCooked !== errCooked) return false; | ||
| } | ||
|
|
||
| // The junction quasi (sfTpl.quasis[prefixExprCount]) must end with errTpl's first quasi. | ||
| const junctionCooked = sfTpl.quasis[prefixExprCount].value.cooked; | ||
| const errFirstCooked = errTpl.quasis[0].value.cooked; | ||
| if (junctionCooked === null || errFirstCooked === null) return false; | ||
| if (!junctionCooked.endsWith(errFirstCooked)) return false; | ||
|
|
||
| // There must be an actual prefix (not an exact-equal pair). | ||
| return prefixExprCount > 0 || junctionCooked.length > errFirstCooked.length; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when `setFailedArg` is a BinaryExpression of the form | ||
| * `<string> + <expr>` where the string literal is non-empty (the prefix) | ||
| * and the right operand is identical in source text to `errorArg`. | ||
| * This captures: | ||
| * core.error(`Failed: ${msg}`) | ||
| * core.setFailed("ERR: " + `Failed: ${msg}`) | ||
| */ | ||
| function isSetFailedArgStringConcatPrefixedVersion(errorArg: TSESTree.Expression, setFailedArg: TSESTree.Expression, sourceCode: SourceCode): boolean { | ||
| if (setFailedArg.type !== AST_NODE_TYPES.BinaryExpression) return false; | ||
| const be = setFailedArg as TSESTree.BinaryExpression; | ||
| if (be.operator !== "+") return false; | ||
|
|
||
| // Left side must be a non-empty string literal (the prefix). | ||
| const left = be.left as TSESTree.Expression; | ||
| if (left.type !== AST_NODE_TYPES.Literal) return false; | ||
| if (typeof (left as TSESTree.Literal).value !== "string") return false; | ||
| if ((left as TSESTree.Literal).value === "") return false; | ||
|
|
||
| // Right side must be identical in source to errorArg. | ||
| const right = be.right as TSESTree.Expression; | ||
| return sourceCode.getText(right) === sourceCode.getText(errorArg); | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the expression is provably side-effect-free: no call, | ||
| * new, or assignment expression at any nesting level. Conservatively returns | ||
|
|
@@ -142,14 +224,23 @@ export const noCoreErrorThenSetFailedRule = createRule({ | |
| // diagnostic context that is not duplicated by setFailed. | ||
| if (hasAnnotationProperties(errorCall)) continue; | ||
|
|
||
| // Only report when the message arguments are provably equivalent (same | ||
| // source text). Calls with different messages are not redundant — the | ||
| // core.error call may log extra context (file names, sizes, stack frames) | ||
| // that setFailed does not repeat. | ||
| // Only report when the message arguments are provably equivalent. | ||
| // Two shapes are recognised: | ||
| // 1. Exact source-text match — the classic identical-message case. | ||
| // 2. Prefixed match — setFailedArg is a TemplateLiteral whose | ||
| // content ends with the full content of errorArg (also a | ||
| // TemplateLiteral), with only a non-empty prefix added in front | ||
| // (e.g. `${ERR_CODE}: ${msg}` vs `${msg}`). This is the dominant | ||
| // anti-pattern in the codebase: core.error duplicates the same | ||
| // annotation that core.setFailed already emits, just without the | ||
| // leading error-code prefix. | ||
| // Calls where setFailed adds suffix or interleaved context are NOT | ||
| // flagged, since they may carry information not duplicated by setFailed. | ||
| const errorArg = getFirstNonSpreadArg(errorCall); | ||
| const setFailedArg = getFirstNonSpreadArg(setFailedCall); | ||
| if (errorArg === null || setFailedArg === null) continue; | ||
| if (sourceCode.getText(errorArg) !== sourceCode.getText(setFailedArg)) continue; | ||
| const exactMatch = sourceCode.getText(errorArg) === sourceCode.getText(setFailedArg); | ||
| if (!exactMatch && !isSetFailedArgPrefixedVersion(errorArg, setFailedArg, sourceCode) && !isSetFailedArgStringConcatPrefixedVersion(errorArg, setFailedArg, sourceCode)) continue; | ||
|
|
||
| // The auto-remove suggestion is semantics-preserving only when the shared | ||
| // argument is provably side-effect-free. For example, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New prefix-matching branches lack tests for zero-expression error templates and null-cooked quasis, leaving two guard conditions unverified.
💡 Details
isSetFailedArgPrefixedVersionhas at least two branches with no covering test case:errTpl.expressions.length === 0(a plain literal-onlycore.error(\Failed`)) paired with asetFailedtemplate that has a prefix expression, e.g.core.setFailed(`${ERR_CODE}: Failed`). This exercisesprefixExprCount > 0with the loop overerrTpl.expressionsnever running — a distinct code path from the two invalid tests added, which both useerrTpl.expressions.length === 1`.cooked === nullguard (if (junctionCooked === null || errFirstCooked === null) return false;) — reachable when a template literal contains an invalid escape sequence — has no test, so a regression that inverts or removes this null check would go undetected.Adding two more
invalid/validcases would close this gap and lock in the intended behavior for both edge cases.