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..bd5fdeeb6a4 --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/cb.fixture.ts @@ -0,0 +1,74 @@ +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'; +import DefaultComponent from 'vue-class-component'; +import RealVue from 'vue'; + +@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; +}; + +// vue-class-component v7 (the Vue 2.7-compatible release) only exports `Component`, as a default +// export; `Vue` itself is imported from the `vue` package directly, not from vue-class-component +@DefaultComponent +export class CanonicalV7Component extends RealVue { // 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..585b569c5ad --- /dev/null +++ b/packages/analysis/src/jsts/rules/S9145/rule.ts @@ -0,0 +1,172 @@ +/* + * 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_CLASS_COMPONENT_MODULE = 'vue-class-component'; +const VUE_FQN = 'vue-class-component.Vue'; +const DECORATOR_FQNS = new Set([ + 'vue-class-component.Component', + 'vue-class-component.Options', + // vue-class-component v7 (the Vue 2.7-compatible release) only exports `Component`, and as a + // default export: `import Component from 'vue-class-component'`. getFullyQualifiedName() + // resolves a default import to the bare module name, not a `.Component`-suffixed FQN. `Vue` + // itself is never re-exported by vue-class-component in any version (v7 imports it from `vue` + // directly, v8 exposes it as a named export), so this bare FQN only ever means the decorator. + VUE_CLASS_COMPONENT_MODULE, +]); +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` (named or, for v7's `Component`, default import), 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 `