From 7277db5e98d0e7e0e1979ce2ebb2e3e787b3f0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillem=20Sard=C3=A0=20Parreu?= Date: Fri, 7 Aug 2026 15:14:31 +0200 Subject: [PATCH 1/3] Implement S9145 --- .../src/jsts/rules/S9145/cb.fixture.js | 11 ++ .../src/jsts/rules/S9145/cb.fixture.ts | 65 +++++++ .../src/jsts/rules/S9145/cb.fixture.vue | 12 ++ .../analysis/src/jsts/rules/S9145/cb.test.ts | 25 +++ .../analysis/src/jsts/rules/S9145/index.ts | 17 ++ .../analysis/src/jsts/rules/S9145/meta.ts | 19 +++ .../analysis/src/jsts/rules/S9145/rule.ts | 161 ++++++++++++++++++ .../vue2-7-project/fixtures/package.json | 5 + .../rules/S9145/vue2-7-project/unit.test.ts | 45 +++++ .../vue2-and-3-project/fixtures/package.json | 5 + .../S9145/vue2-and-3-project/unit.test.ts | 45 +++++ .../S9145/vue2-project/fixtures/package.json | 5 + .../rules/S9145/vue2-project/unit.test.ts | 52 ++++++ .../S9145/vue3-project/fixtures/package.json | 5 + .../rules/S9145/vue3-project/unit.test.ts | 51 ++++++ 15 files changed, 523 insertions(+) create mode 100644 packages/analysis/src/jsts/rules/S9145/cb.fixture.js create mode 100644 packages/analysis/src/jsts/rules/S9145/cb.fixture.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/cb.fixture.vue create mode 100644 packages/analysis/src/jsts/rules/S9145/cb.test.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/index.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/meta.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/rule.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/vue2-7-project/fixtures/package.json create mode 100644 packages/analysis/src/jsts/rules/S9145/vue2-7-project/unit.test.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/vue2-and-3-project/fixtures/package.json create mode 100644 packages/analysis/src/jsts/rules/S9145/vue2-and-3-project/unit.test.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/vue2-project/fixtures/package.json create mode 100644 packages/analysis/src/jsts/rules/S9145/vue2-project/unit.test.ts create mode 100644 packages/analysis/src/jsts/rules/S9145/vue3-project/fixtures/package.json create mode 100644 packages/analysis/src/jsts/rules/S9145/vue3-project/unit.test.ts diff --git a/packages/analysis/src/jsts/rules/S9145/cb.fixture.js b/packages/analysis/src/jsts/rules/S9145/cb.fixture.js new file mode 100644 index 00000000000..adaad6361f4 --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/cb.fixture.js @@ -0,0 +1,11 @@ +import { Vue } from 'vue-class-component'; + +export default class MyComponent extends Vue { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + count = 0; + + increment() { + this.count++; + } +} + +class UnrelatedBaseClass extends SomeOtherBase {} // compliant: superclass is unrelated to vue-class-component diff --git a/packages/analysis/src/jsts/rules/S9145/cb.fixture.ts b/packages/analysis/src/jsts/rules/S9145/cb.fixture.ts new file mode 100644 index 00000000000..a2a5d2056f1 --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/cb.fixture.ts @@ -0,0 +1,65 @@ +import { Options, Vue } from 'vue-class-component'; +import { Component as VueComponent, Vue as VueBase } from 'vue-class-component'; +import { Component as FacingComponent } from 'vue-facing-decorator'; +import { Vue as PropertyDecoratorVue, Prop, Watch as VueWatch } from 'vue-property-decorator'; + +@Options({ + props: { + message: String, + }, +}) +export default class MyComponent extends Vue { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} +// ^^^^^^^^^^^ + count = 0; +} + +class ExtendsOnly extends Vue { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + count = 0; + + increment() { + this.count++; + } +} + +@VueComponent +class BareDecoratorOnly {} // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + +class AliasedSuperclass extends VueBase { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + count = 0; +} + +const AnonymousClassExpression = class extends Vue { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + count = 0; +}; + +function Component(_unused: unknown) { + return () => {}; +} + +@Component +class LocallyDefinedDecorator {} // compliant: "Component" here is not imported from vue-class-component + +@FacingComponent +class UsesFacingDecorator {} // compliant: vue-facing-decorator is explicitly out of scope + +class UnrelatedBaseClass extends SomeOtherBase {} // compliant: superclass is unrelated to vue-class-component + +class UsesPropertyDecorator extends PropertyDecoratorVue { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + // extends a superclass unrelated to vue-class-component, but the @Prop decorator below + // is imported from vue-property-decorator, which is also deprecated and archived + @Prop() readonly msg!: string; +} + +class AliasedPropertyDecorator { // Noncompliant {{Replace this deprecated Vue class-based component pattern with the Composition API.}} + @VueWatch('someProp') + onSomePropChanged() {} +} + +function Watch(_unused: string) { + return () => {}; +} + +class LocallyDefinedPropertyDecorator { + @Watch('someProp') // compliant: "Watch" here is not imported from vue-property-decorator + onSomePropChanged() {} +} diff --git a/packages/analysis/src/jsts/rules/S9145/cb.fixture.vue b/packages/analysis/src/jsts/rules/S9145/cb.fixture.vue new file mode 100644 index 00000000000..aad648bb7ca --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/cb.fixture.vue @@ -0,0 +1,12 @@ + diff --git a/packages/analysis/src/jsts/rules/S9145/cb.test.ts b/packages/analysis/src/jsts/rules/S9145/cb.test.ts new file mode 100644 index 00000000000..9203e277216 --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/cb.test.ts @@ -0,0 +1,25 @@ +/* + * SonarQube JavaScript Plugin + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +// https://sonarsource.github.io/rspec/#/rspec/S9145/javascript +import { test } from '../../../../tests/jsts/tools/testers/comment-based/checker.js'; +import { rule } from './index.js'; +import { describe } from 'node:test'; +import * as meta from './generated-meta.js'; + +describe(`Rule S9145`, () => { + test(meta, rule, import.meta.dirname); +}); diff --git a/packages/analysis/src/jsts/rules/S9145/index.ts b/packages/analysis/src/jsts/rules/S9145/index.ts new file mode 100644 index 00000000000..6abe8e6e2e6 --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/index.ts @@ -0,0 +1,17 @@ +/* + * SonarQube JavaScript Plugin + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +export { rule } from './rule.js'; diff --git a/packages/analysis/src/jsts/rules/S9145/meta.ts b/packages/analysis/src/jsts/rules/S9145/meta.ts new file mode 100644 index 00000000000..6f6e3c730ff --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/meta.ts @@ -0,0 +1,19 @@ +/* + * SonarQube JavaScript Plugin + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +// https://sonarsource.github.io/rspec/#/rspec/S9145/javascript +export const implementation = 'original'; +export const eslintId = 'no-vue-class-component'; diff --git a/packages/analysis/src/jsts/rules/S9145/rule.ts b/packages/analysis/src/jsts/rules/S9145/rule.ts new file mode 100644 index 00000000000..6d267036a90 --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/rule.ts @@ -0,0 +1,161 @@ +/* + * SonarQube JavaScript Plugin + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +// https://sonarsource.github.io/rspec/#/rspec/S9145/javascript + +import type { Rule } from 'eslint'; +import type { TSESTree } from '@typescript-eslint/utils'; +import type estree from 'estree'; +import { intersects, validRange } from 'semver'; +import { isFunctionCall, isIdentifier } from '../helpers/ast.js'; +import { getVueVersion } from '../helpers/dependency-manifests/dependencies.js'; +import { generateMeta } from '../helpers/generate-meta.js'; +import { getFullyQualifiedName } from '../helpers/module.js'; +import * as meta from './generated-meta.js'; + +const messages = { + deprecatedClassComponent: + 'Replace this deprecated Vue class-based component pattern with the Composition API.', +}; + +const VUE_FQN = 'vue-class-component.Vue'; +const DECORATOR_FQNS = new Set(['vue-class-component.Component', 'vue-class-component.Options']); +const PROPERTY_DECORATOR_FQNS = new Set( + [ + 'Prop', + 'PropSync', + 'Model', + 'ModelSync', + 'Watch', + 'Provide', + 'Inject', + 'ProvideReactive', + 'InjectReactive', + 'Emit', + 'Ref', + 'VModel', + ].map(name => `vue-property-decorator.${name}`), +); +const VUE_WITH_COMPOSITION_API_RANGE = '>=2.7.0'; + +type ClassNode = TSESTree.ClassDeclaration | TSESTree.ClassExpression; + +export const rule: Rule.RuleModule = { + meta: generateMeta(meta, { messages }), + create(context: Rule.RuleContext) { + if (lacksCompositionApi(context)) { + return {}; + } + + function checkClass(node: estree.Node) { + const classNode = node as unknown as ClassNode; + if ( + extendsVueClassComponent(context, classNode) || + hasVueClassComponentDecorator(context, classNode) || + hasVuePropertyDecoratorUsage(context, classNode) + ) { + context.report({ + node: getReportNode(classNode), + messageId: 'deprecatedClassComponent', + }); + } + } + + return { + ClassDeclaration: checkClass, + ClassExpression: checkClass, + }; + }, +}; + +/** + * Whether the class extends the `Vue` base class imported from `vue-class-component`. + * Resolved by import origin, not by the local (possibly aliased) identifier name. + */ +function extendsVueClassComponent(context: Rule.RuleContext, classNode: ClassNode): boolean { + const { superClass } = classNode; + return ( + superClass?.type === 'Identifier' && + getFullyQualifiedName(context, superClass as unknown as estree.Node) === VUE_FQN + ); +} + +/** + * Whether the class carries a `@Component` or `@Options` decorator imported from + * `vue-class-component`, whether used bare (`@Component`) or called (`@Options({...})`). + * Resolved by import origin so lookalikes from other libraries (e.g. `vue-facing-decorator`) + * or locally-defined decorators of the same name are not flagged. + */ +function hasVueClassComponentDecorator(context: Rule.RuleContext, classNode: ClassNode): boolean { + return (classNode.decorators ?? []).some(decorator => + isDecoratorFromModule(context, decorator, DECORATOR_FQNS), + ); +} + +/** + * Whether the class or one of its members carries a `vue-property-decorator` decorator + * (`@Prop`, `@Watch`, `@Emit`, ...). `vue-property-decorator` is deprecated and archived just + * like `vue-class-component` (which it depends on), so its decorators are an equally strong, + * import-resolved signal of a class-based Vue component, even without an explicit `extends Vue` + * or `@Component`/`@Options` on the class itself. + */ +function hasVuePropertyDecoratorUsage(context: Rule.RuleContext, classNode: ClassNode): boolean { + const classDecorators = classNode.decorators ?? []; + const memberDecorators = classNode.body.body.flatMap( + member => (member as { decorators?: TSESTree.Decorator[] }).decorators ?? [], + ); + return [...classDecorators, ...memberDecorators].some(decorator => + isDecoratorFromModule(context, decorator, PROPERTY_DECORATOR_FQNS), + ); +} + +/** + * Whether `decorator`'s expression, bare (`@Foo`) or called (`@Foo({...})`), resolves by import + * origin to one of `fqns`. + */ +function isDecoratorFromModule( + context: Rule.RuleContext, + decorator: TSESTree.Decorator, + fqns: ReadonlySet, +): boolean { + const expression = decorator.expression as unknown as estree.Node; + const target = isFunctionCall(expression) ? expression.callee : expression; + return isIdentifier(target) && fqns.has(getFullyQualifiedName(context, target) ?? ''); +} + +function getReportNode(classNode: ClassNode): estree.Node { + return (classNode.id ?? classNode) as unknown as estree.Node; +} + +/** + * Returns true when the project's Vue dependency range cannot possibly resolve to a version + * that has the Composition API. + * + * vue-class-component's (and vue-property-decorator's) class API was the standard, recommended + * way to write components before the Composition API existed, so it is not deprecated on + * versions that predate it. Vue backported the Composition API and `