Skip to content

Commit 34c1809

Browse files
sonar-nigel[bot]Vibe Botclaudefrancois-mora-sonarsource
authored
JS-1381 Fix S6598 false positive for interfaces used as defineEmits type argument in Vue (#6525)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Francois Mora <francois.mora@sonarsource.com>
1 parent 39064fb commit 34c1809

2 files changed

Lines changed: 161 additions & 3 deletions

File tree

packages/jsts/src/rules/S6598/decorator.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,79 @@
1717
// https://sonarsource.github.io/rspec/#/rspec/S6598/javascript
1818

1919
import type { Rule } from 'eslint';
20+
import type estree from 'estree';
21+
import type { TSESTree } from '@typescript-eslint/utils';
2022
import { generateMeta } from '../helpers/generate-meta.js';
2123
import { interceptReport } from '../helpers/decorators/interceptor.js';
24+
import { isInsideVueSetupScript } from '../helpers/vue.js';
2225
import * as meta from './generated-meta.js';
2326

27+
function getTopLevelCallExprs(context: Rule.RuleContext): TSESTree.CallExpression[] {
28+
return context.sourceCode.ast.body.flatMap(stmt => {
29+
if (stmt.type === 'ExpressionStatement' && stmt.expression.type === 'CallExpression') {
30+
return [stmt.expression as unknown as TSESTree.CallExpression];
31+
}
32+
if (stmt.type === 'VariableDeclaration') {
33+
return stmt.declarations
34+
.filter(decl => decl.init?.type === 'CallExpression')
35+
.map(decl => decl.init as unknown as TSESTree.CallExpression);
36+
}
37+
return [];
38+
});
39+
}
40+
41+
function isReferencedByDefineEmits(name: string, context: Rule.RuleContext): boolean {
42+
return getTopLevelCallExprs(context).some(call => {
43+
if (call.callee.type !== 'Identifier' || call.callee.name !== 'defineEmits') {
44+
return false;
45+
}
46+
return (
47+
call.typeArguments?.params?.some(
48+
p =>
49+
p.type === 'TSTypeReference' &&
50+
p.typeName.type === 'Identifier' &&
51+
p.typeName.name === name,
52+
) ?? false
53+
);
54+
});
55+
}
56+
57+
function isDefineEmitsTypeArg(
58+
node: TSESTree.TSCallSignatureDeclaration,
59+
context: Rule.RuleContext,
60+
): boolean {
61+
const { parent } = node;
62+
if (parent.type === 'TSTypeLiteral') {
63+
const grandParent = parent.parent;
64+
// Inline type literal used directly as a defineEmits type argument, e.g.:
65+
// defineEmits<{ ...function signature... }>()
66+
if (grandParent.type === 'TSTypeParameterInstantiation') {
67+
const greatGrandParent = grandParent.parent;
68+
if (greatGrandParent.type === 'CallExpression') {
69+
const { callee } = greatGrandParent;
70+
return callee.type === 'Identifier' && callee.name === 'defineEmits';
71+
}
72+
return false;
73+
}
74+
// Type alias whose body contains the call signature, referenced by defineEmits, e.g.:
75+
// type Emits = { ...function signature... }
76+
// defineEmits<Emits>()
77+
if (grandParent.type === 'TSTypeAliasDeclaration') {
78+
return isReferencedByDefineEmits(grandParent.id.name, context);
79+
}
80+
return false;
81+
}
82+
// Interface whose body contains the call signature, referenced by defineEmits, e.g.:
83+
// interface Emits { ...function signature... }
84+
// defineEmits<Emits>()
85+
if (parent.type === 'TSInterfaceBody') {
86+
return isReferencedByDefineEmits(parent.parent.id.name, context);
87+
}
88+
return false;
89+
}
90+
2491
// Rewording one of the issue messages reported by the core implementation.
92+
// Suppresses false positives for defineEmits type arguments in Vue <script setup>.
2593
export function decorate(rule: Rule.RuleModule): Rule.RuleModule {
2694
return interceptReport(
2795
{
@@ -37,9 +105,18 @@ export function decorate(rule: Rule.RuleModule): Rule.RuleModule {
37105
}),
38106
},
39107
(context, reportDescriptor) => {
40-
context.report({
41-
...reportDescriptor,
42-
});
108+
const node = (reportDescriptor as unknown as { node: TSESTree.Node }).node;
109+
if (node.type !== 'TSCallSignatureDeclaration') {
110+
context.report({ ...reportDescriptor });
111+
return;
112+
}
113+
if (!isInsideVueSetupScript(node as unknown as estree.Node, context)) {
114+
context.report({ ...reportDescriptor });
115+
return;
116+
}
117+
if (!isDefineEmitsTypeArg(node, context)) {
118+
context.report({ ...reportDescriptor });
119+
}
43120
},
44121
);
45122
}

packages/jsts/src/rules/S6598/unit.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
1717
import { rule } from './index.js';
18+
import { rules as tsEslintRules } from '../external/typescript-eslint/index.js';
1819
import { NoTypeCheckingRuleTester } from '../../../tests/tools/testers/rule-tester.js';
1920
import { describe, it } from 'node:test';
21+
import vueParser from 'vue-eslint-parser';
22+
import tsParser from '@typescript-eslint/parser';
2023

2124
describe('S6598', () => {
2225
it('S6598', () => {
2326
const ruleTester = new NoTypeCheckingRuleTester();
27+
const vueRuleTester = new NoTypeCheckingRuleTester({
28+
parser: vueParser,
29+
parserOptions: { parser: tsParser },
30+
});
2431

2532
ruleTester.run(`Decorated rule should reword the issue message`, rule, {
2633
valid: [
@@ -41,5 +48,79 @@ describe('S6598', () => {
4148
},
4249
],
4350
});
51+
52+
vueRuleTester.run('defineEmits type argument in Vue script setup', rule, {
53+
valid: [
54+
{
55+
// Compliant: named interface as defineEmits type arg
56+
code: `<script setup lang="ts">
57+
interface ClickEmits {
58+
(e: 'click'): void;
59+
}
60+
const emit = defineEmits<ClickEmits>();
61+
</script>`,
62+
},
63+
{
64+
// Compliant: inline type literal as defineEmits type arg
65+
code: `<script setup lang="ts">
66+
const emit = defineEmits<{
67+
(e: 'submit'): void;
68+
}>();
69+
</script>`,
70+
},
71+
{
72+
// Compliant: named type alias as defineEmits type arg
73+
code: `<script setup lang="ts">
74+
type ChangeEmits = {
75+
(e: 'change', value: string): void;
76+
};
77+
const emit = defineEmits<ChangeEmits>();
78+
</script>`,
79+
},
80+
],
81+
invalid: [
82+
{
83+
// Interface unrelated to defineEmits is still flagged
84+
code: `<script setup lang="ts">
85+
interface MyTransformer {
86+
(input: string): number;
87+
}
88+
</script>`,
89+
errors: 1,
90+
output: `<script setup lang="ts">
91+
type MyTransformer = (input: string) => number;
92+
</script>`,
93+
},
94+
],
95+
});
96+
97+
// Sentinel: verify the upstream rule still raises an issue on the defineEmits FP pattern.
98+
// If this test fails, upstream prefer-function-type no longer flags this pattern natively,
99+
// and the suppression logic in decorator.ts can be safely removed.
100+
new NoTypeCheckingRuleTester({
101+
parser: vueParser,
102+
parserOptions: { parser: tsParser },
103+
}).run(
104+
'upstream prefer-function-type raises issue on defineEmits type arg (sentinel)',
105+
tsEslintRules['prefer-function-type'],
106+
{
107+
valid: [],
108+
invalid: [
109+
{
110+
code: `<script setup lang="ts">
111+
interface ClickEmits {
112+
(e: 'click'): void;
113+
}
114+
const emit = defineEmits<ClickEmits>();
115+
</script>`,
116+
errors: 1,
117+
output: `<script setup lang="ts">
118+
type ClickEmits = (e: 'click') => void;
119+
const emit = defineEmits<ClickEmits>();
120+
</script>`,
121+
},
122+
],
123+
},
124+
);
44125
});
45126
});

0 commit comments

Comments
 (0)