From 58d8f56e95728169867f59b6b3d299a816572dfd Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 7 Aug 2026 03:57:53 +0200 Subject: [PATCH] Linter: Implement `ujs-` rules for the deprecated `@rails/ujs` attributes --- .../packages/linter/docs/rules/README.md | 7 + .../docs/rules/ujs-no-remote-attribute.md | 59 +++++++ .../docs/rules/ujs-prefer-turbo-confirm.md | 57 +++++++ .../docs/rules/ujs-prefer-turbo-method.md | 59 +++++++ .../rules/ujs-prefer-turbo-submits-with.md | 57 +++++++ javascript/packages/linter/src/rules.ts | 10 ++ .../linter/src/rules/action-view-utils.ts | 11 +- javascript/packages/linter/src/rules/index.ts | 6 + .../packages/linter/src/rules/ujs-base.ts | 153 ++++++++++++++++++ .../src/rules/ujs-no-remote-attribute.ts | 44 +++++ .../src/rules/ujs-prefer-turbo-confirm.ts | 39 +++++ .../src/rules/ujs-prefer-turbo-method.ts | 44 +++++ .../rules/ujs-prefer-turbo-submits-with.ts | 39 +++++ .../test/__snapshots__/cli.test.ts.snap | 110 ++++++------- .../rules/ujs-no-remote-attribute.test.ts | 120 ++++++++++++++ .../rules/ujs-prefer-turbo-confirm.test.ts | 90 +++++++++++ .../rules/ujs-prefer-turbo-method.test.ts | 149 +++++++++++++++++ .../ujs-prefer-turbo-submits-with.test.ts | 85 ++++++++++ 18 files changed, 1083 insertions(+), 56 deletions(-) create mode 100644 javascript/packages/linter/docs/rules/ujs-no-remote-attribute.md create mode 100644 javascript/packages/linter/docs/rules/ujs-prefer-turbo-confirm.md create mode 100644 javascript/packages/linter/docs/rules/ujs-prefer-turbo-method.md create mode 100644 javascript/packages/linter/docs/rules/ujs-prefer-turbo-submits-with.md create mode 100644 javascript/packages/linter/src/rules/ujs-base.ts create mode 100644 javascript/packages/linter/src/rules/ujs-no-remote-attribute.ts create mode 100644 javascript/packages/linter/src/rules/ujs-prefer-turbo-confirm.ts create mode 100644 javascript/packages/linter/src/rules/ujs-prefer-turbo-method.ts create mode 100644 javascript/packages/linter/src/rules/ujs-prefer-turbo-submits-with.ts create mode 100644 javascript/packages/linter/test/rules/ujs-no-remote-attribute.test.ts create mode 100644 javascript/packages/linter/test/rules/ujs-prefer-turbo-confirm.test.ts create mode 100644 javascript/packages/linter/test/rules/ujs-prefer-turbo-method.test.ts create mode 100644 javascript/packages/linter/test/rules/ujs-prefer-turbo-submits-with.test.ts diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 216deb29f..1d4448482 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -162,6 +162,13 @@ This page contains documentation for all Herb Linter rules. - [`turbo-permanent-no-misleading-value`](./turbo-permanent-no-misleading-value.md) - Disallow misleading values on `data-turbo-permanent` - [`turbo-permanent-require-id`](./turbo-permanent-require-id.md) - Require `id` attribute on elements with `data-turbo-permanent` +#### UJS + +- [`ujs-no-remote-attribute`](./ujs-no-remote-attribute.md) - Disallow the deprecated `data-remote` attribute and helper option +- [`ujs-prefer-turbo-confirm`](./ujs-prefer-turbo-confirm.md) - Prefer `data-turbo-confirm` over the deprecated `data-confirm` +- [`ujs-prefer-turbo-method`](./ujs-prefer-turbo-method.md) - Prefer `data-turbo-method` over the deprecated `data-method` +- [`ujs-prefer-turbo-submits-with`](./ujs-prefer-turbo-submits-with.md) - Prefer `data-turbo-submits-with` over the deprecated `data-disable-with` + ## Contributing diff --git a/javascript/packages/linter/docs/rules/ujs-no-remote-attribute.md b/javascript/packages/linter/docs/rules/ujs-no-remote-attribute.md new file mode 100644 index 000000000..95184e754 --- /dev/null +++ b/javascript/packages/linter/docs/rules/ujs-no-remote-attribute.md @@ -0,0 +1,59 @@ +# Linter Rule: Disallow the deprecated `data-remote` attribute + +**Rule:** `ujs-no-remote-attribute` + +## Description + +Disallow the `data-remote` attribute and the Action View helper options that render it, namely `remote:` and `data: { remote: ... }`. Unlike the other deprecated `@rails/ujs` attributes, this one has no Turbo attribute to swap in. Turbo handles links and form submissions by default, so the attribute is removed rather than replaced. + +## Rationale + +Before Rails 7, Rails shipped `@rails/ujs` by default, which added JavaScript behavior to elements through helper options and `data-*` attributes. Rails 7 stopped including it, and Turbo covers the same behavior with its own attributes. + +`data-remote` made `@rails/ujs` issue the request over Ajax instead of navigating, and hand the response to the browser as executable JavaScript. Turbo Drive intercepts links and form submissions on the whole page already, so there is nothing to opt into and no `data-turbo-remote` to write. + +Once `@rails/ujs` is gone the attribute is inert, and what is left is markup that claims a behavior the page no longer has. Because the attribute reads as deliberate, it hides the fact that these requests are now plain navigations. + +## Examples + +### ✅ Good + +```erb +Load posts +``` + +```erb +<%= link_to "Load posts", posts_path %> +``` + +### 🚫 Bad + +```erb +Load posts +``` + +```erb +<%= link_to "Load posts", posts_path, remote: true %> +``` + +```erb +<%= link_to "Load posts", posts_path, data: { remote: true } %> +``` + +## Migration + +For most links and forms the attribute is simply deleted, because Turbo Drive already does what `data-remote` asked for. + +This is the one deprecated `@rails/ujs` attribute whose removal is not always a drop-in change, so it is worth checking what the endpoint returns before deleting it. A `data-remote` request whose response rendered JavaScript needs that response to become a Turbo Stream, or the element needs to live inside a Turbo Frame. Removing the attribute without making that change turns what was a background request into a full page navigation. + +## Related Rules + +* [`ujs-prefer-turbo-method`](./ujs-prefer-turbo-method.md) +* [`ujs-prefer-turbo-confirm`](./ujs-prefer-turbo-confirm.md) +* [`ujs-prefer-turbo-submits-with`](./ujs-prefer-turbo-submits-with.md) + +## References + +* [Rails Guides: Working with JavaScript in Rails](https://guides.rubyonrails.org/working_with_javascript_in_rails.html) +* [Turbo Handbook: Drive](https://turbo.hotwired.dev/handbook/drive) +* [Turbo Handbook: Streams](https://turbo.hotwired.dev/handbook/streams) diff --git a/javascript/packages/linter/docs/rules/ujs-prefer-turbo-confirm.md b/javascript/packages/linter/docs/rules/ujs-prefer-turbo-confirm.md new file mode 100644 index 000000000..226e6d1b8 --- /dev/null +++ b/javascript/packages/linter/docs/rules/ujs-prefer-turbo-confirm.md @@ -0,0 +1,57 @@ +# Linter Rule: Prefer `data-turbo-confirm` over the deprecated `data-confirm` + +**Rule:** `ujs-prefer-turbo-confirm` + +## Description + +Disallow the `data-confirm` attribute and the Action View helper option that renders it, `data: { confirm: ... }`. Use `data-turbo-confirm` or `data: { turbo_confirm: ... }` instead. + +## Rationale + +Before Rails 7, Rails shipped `@rails/ujs` by default, which added JavaScript behavior to elements through helper options and `data-*` attributes. Rails 7 stopped including it, and Turbo covers the same behavior with its own attributes. + +`data-confirm` made `@rails/ujs` prompt the user with the given question before proceeding, and cancel the action if the user declined. `data-turbo-confirm` is a drop-in replacement, so the migration is mechanical. + +Once `@rails/ujs` is gone the attribute is inert, and the failure is silent rather than loud: the link or button still works, but the confirmation prompt simply stops appearing. A destructive action that was guarded now fires on the first click. + +## Examples + +### ✅ Good + +```erb +Delete +``` + +```erb +<%= link_to "Delete", post_path(@post), data: { turbo_confirm: "Are you sure?" } %> +``` + +```erb +<%= button_to "Delete", post_path(@post), data: { turbo_confirm: "Are you sure?" } %> +``` + +### 🚫 Bad + +```erb +Delete +``` + +```erb +<%= link_to "Delete", post_path(@post), data: { confirm: "Are you sure?" } %> +``` + +```erb +<%= button_to "Delete", post_path(@post), data: { confirm: "Are you sure?" } %> +``` + +## Related Rules + +* [`ujs-prefer-turbo-method`](./ujs-prefer-turbo-method.md) +* [`ujs-prefer-turbo-submits-with`](./ujs-prefer-turbo-submits-with.md) +* [`ujs-no-remote-attribute`](./ujs-no-remote-attribute.md) + +## References + +* [Rails `link_to` API](https://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to) +* [Rails Guides: Working with JavaScript in Rails](https://guides.rubyonrails.org/working_with_javascript_in_rails.html) +* [Turbo Handbook: Drive](https://turbo.hotwired.dev/handbook/drive) diff --git a/javascript/packages/linter/docs/rules/ujs-prefer-turbo-method.md b/javascript/packages/linter/docs/rules/ujs-prefer-turbo-method.md new file mode 100644 index 000000000..6e7e70234 --- /dev/null +++ b/javascript/packages/linter/docs/rules/ujs-prefer-turbo-method.md @@ -0,0 +1,59 @@ +# Linter Rule: Prefer `data-turbo-method` over the deprecated `data-method` + +**Rule:** `ujs-prefer-turbo-method` + +## Description + +Disallow the `data-method` attribute and the Action View link helper options that render it, namely `method:` and `data: { method: ... }`. Use `data-turbo-method` or `data: { turbo_method: ... }` instead. + +## Rationale + +Before Rails 7, Rails shipped `@rails/ujs` by default, which added JavaScript behavior to elements through helper options and `data-*` attributes. Rails 7 stopped including it, and Turbo covers the same behavior with its own attributes. + +`data-method` made `@rails/ujs` build a hidden form and submit it with the given verb, so that a plain link could issue a `DELETE`, `PATCH`, `POST` or `PUT` request. `data-turbo-method` is a drop-in replacement, so the migration is mechanical. + +Once `@rails/ujs` is gone the attribute is inert, and the failure is silent rather than loud: the link still works, but it issues a `GET` to the same URL. A "Delete" link quietly turns into a link that shows the record instead of destroying it. + +Note that `method:` on `button_to` and the `form_*` helpers is unaffected. Those render a real form and set the verb through a hidden `_method` field, which never involved `@rails/ujs`. + +## Examples + +### ✅ Good + +```erb +Delete +``` + +```erb +<%= link_to "Delete", post_path(@post), data: { turbo_method: :delete } %> +``` + +```erb +<%= button_to "Delete", post_path(@post), method: :delete %> +``` + +### 🚫 Bad + +```erb +Delete +``` + +```erb +<%= link_to "Delete", post_path(@post), method: :delete %> +``` + +```erb +<%= link_to "Delete", post_path(@post), data: { method: :delete } %> +``` + +## Related Rules + +* [`ujs-prefer-turbo-confirm`](./ujs-prefer-turbo-confirm.md) +* [`ujs-prefer-turbo-submits-with`](./ujs-prefer-turbo-submits-with.md) +* [`ujs-no-remote-attribute`](./ujs-no-remote-attribute.md) + +## References + +* [Rails `link_to` API](https://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to) +* [Rails Guides: Working with JavaScript in Rails](https://guides.rubyonrails.org/working_with_javascript_in_rails.html) +* [Turbo Handbook: Drive](https://turbo.hotwired.dev/handbook/drive) diff --git a/javascript/packages/linter/docs/rules/ujs-prefer-turbo-submits-with.md b/javascript/packages/linter/docs/rules/ujs-prefer-turbo-submits-with.md new file mode 100644 index 000000000..e94b1d55e --- /dev/null +++ b/javascript/packages/linter/docs/rules/ujs-prefer-turbo-submits-with.md @@ -0,0 +1,57 @@ +# Linter Rule: Prefer `data-turbo-submits-with` over the deprecated `data-disable-with` + +**Rule:** `ujs-prefer-turbo-submits-with` + +## Description + +Disallow the `data-disable-with` attribute and the Action View helper option that renders it, `data: { disable_with: ... }`. Use `data-turbo-submits-with` or `data: { turbo_submits_with: ... }` instead. + +## Rationale + +Before Rails 7, Rails shipped `@rails/ujs` by default, which added JavaScript behavior to elements through helper options and `data-*` attributes. Rails 7 stopped including it, and Turbo covers the same behavior with its own attributes. + +`data-disable-with` made `@rails/ujs` disable the submit button and swap its label for the given text while the request was in flight, which is what stopped users from double submitting a form. `data-turbo-submits-with` is a drop-in replacement, so the migration is mechanical. + +Once `@rails/ujs` is gone the attribute is inert, and the failure is silent rather than loud: the form still submits, but the button stays live and keeps its original label. Double submissions become possible again on exactly the forms that were annotated to prevent them. + +## Examples + +### ✅ Good + +```erb + +``` + +```erb +<%= f.submit "Save", data: { turbo_submits_with: "Saving..." } %> +``` + +```erb +<%= submit_tag "Save", data: { turbo_submits_with: "Saving..." } %> +``` + +### 🚫 Bad + +```erb + +``` + +```erb +<%= f.submit "Save", data: { disable_with: "Saving..." } %> +``` + +```erb +<%= submit_tag "Save", data: { disable_with: "Saving..." } %> +``` + +## Related Rules + +* [`ujs-prefer-turbo-method`](./ujs-prefer-turbo-method.md) +* [`ujs-prefer-turbo-confirm`](./ujs-prefer-turbo-confirm.md) +* [`ujs-no-remote-attribute`](./ujs-no-remote-attribute.md) + +## References + +* [Rails `submit_tag` API](https://api.rubyonrails.org/classes/ActionView/Helpers/FormTagHelper.html#method-i-submit_tag) +* [Rails Guides: Working with JavaScript in Rails](https://guides.rubyonrails.org/working_with_javascript_in_rails.html) +* [Turbo Handbook: Drive](https://turbo.hotwired.dev/handbook/drive) diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 204e8f55c..0a4d6d1db 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -133,6 +133,11 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio import { TurboPermanentNoMisleadingValueRule } from "./rules/turbo-permanent-no-misleading-value.js" import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js" +import { UJSNoRemoteAttributeRule } from "./rules/ujs-no-remote-attribute.js" +import { UJSPreferTurboConfirmRule } from "./rules/ujs-prefer-turbo-confirm.js" +import { UJSPreferTurboMethodRule } from "./rules/ujs-prefer-turbo-method.js" +import { UJSPreferTurboSubmitsWithRule } from "./rules/ujs-prefer-turbo-submits-with.js" + export const rules: RuleClass[] = [ A11yAvoidGenericLinkTextRule, A11yDisabledAttributeRule, @@ -266,4 +271,9 @@ export const rules: RuleClass[] = [ TurboPermanentNoMisleadingValueRule, TurboPermanentRequireIdRule, + + UJSNoRemoteAttributeRule, + UJSPreferTurboConfirmRule, + UJSPreferTurboMethodRule, + UJSPreferTurboSubmitsWithRule, ] diff --git a/javascript/packages/linter/src/rules/action-view-utils.ts b/javascript/packages/linter/src/rules/action-view-utils.ts index b5fed3d12..78d872ba6 100644 --- a/javascript/packages/linter/src/rules/action-view-utils.ts +++ b/javascript/packages/linter/src/rules/action-view-utils.ts @@ -1,4 +1,4 @@ -import { isPrismNodeType, getHelperEntries } from "@herb-tools/core" +import { isPrismNodeType, getHelperEntries, getHelpersForTag } from "@herb-tools/core" import type { PrismNode } from "@herb-tools/core" const ACTION_VIEW_HELPER_NAMES = new Set( @@ -7,6 +7,15 @@ const ACTION_VIEW_HELPER_NAMES = new Set( .flatMap(helper => [helper.name, ...helper.aliases]) ) +export function helperNamesForTags(...tagNames: string[]): ReadonlySet { + return new Set( + tagNames + .flatMap(tagName => getHelpersForTag(tagName)) + .filter(helper => helper.visibility === "public") + .flatMap(helper => [helper.name, ...helper.aliases]) + ) +} + export function isTagBuilderCall(prismNode: PrismNode): boolean { if (!isPrismNodeType(prismNode, "CallNode")) return false if (!isPrismNodeType(prismNode.receiver, "CallNode")) return false diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 3fd5ed72d..6bc0f3118 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -14,6 +14,7 @@ export * from "./file-utils.js" export * from "./string-utils.js" export * from "./action-view-utils.js" export * from "./herb-disable-comment-base.js" +export * from "./ujs-base.js" export * from "./actionview-no-dynamic-partial-path.js" export * from "./actionview-no-helper-shadowing.js" @@ -129,3 +130,8 @@ export * from "./html-tag-name-lowercase.js" export * from "./source-indentation.js" export * from "./svg-tag-name-capitalization.js" + +export * from "./ujs-no-remote-attribute.js" +export * from "./ujs-prefer-turbo-confirm.js" +export * from "./ujs-prefer-turbo-method.js" +export * from "./ujs-prefer-turbo-submits-with.js" diff --git a/javascript/packages/linter/src/rules/ujs-base.ts b/javascript/packages/linter/src/rules/ujs-base.ts new file mode 100644 index 000000000..0d3a361b9 --- /dev/null +++ b/javascript/packages/linter/src/rules/ujs-base.ts @@ -0,0 +1,153 @@ +import { BaseRuleVisitor } from "./rule-utils.js" + +import { PrismVisitor, filterHTMLAttributeNodes, getAttributeName, isPrismNodeType, locationFromByteOffset } from "@herb-tools/core" + +import type { ERBBlockNode, ERBContentNode, ERBOpenTagNode, HTMLOpenTagNode, Node, PrismNode } from "@herb-tools/core" +import type { LintContext } from "../types.js" + +export interface UJSAttributeDescriptor { + attribute: string + dataKey: string + replacement: { attribute: string, option: string } | null + keyword?: { name: string, helpers: ReadonlySet } +} + +function attributeMessage({ attribute, replacement }: UJSAttributeDescriptor): string { + if (!replacement) { + return `Avoid the deprecated \`@rails/ujs\` attribute \`${attribute}\`. Turbo handles links and form submissions by default, so it can be removed.` + } + + return `Avoid the deprecated \`@rails/ujs\` attribute \`${attribute}\`. Use \`${replacement.attribute}\` instead.` +} + +function optionMessage({ attribute, replacement }: UJSAttributeDescriptor): string { + if (!replacement) { + return `Avoid the deprecated \`@rails/ujs\` option, which renders \`${attribute}\`. Turbo handles links and form submissions by default, so it can be removed.` + } + + return `Avoid the deprecated \`@rails/ujs\` option, which renders \`${attribute}\`. Use \`${replacement.option}\` instead.` +} + +function symbolKey(node: PrismNode): string | null { + if (!isPrismNodeType(node, "SymbolNode")) return null + + return node.unescaped?.value ?? null +} + +class UJSOptionCollector extends PrismVisitor { + public readonly keys: PrismNode[] = [] + + constructor(private readonly descriptor: UJSAttributeDescriptor) { + super() + } + + visitCallNode(node: PrismNode): void { + this.checkKeywordArguments(node) + + this.visitChildNodes(node) + } + + private checkKeywordArguments(node: PrismNode): void { + const argumentNodes = node.arguments_?.arguments_ + + if (!Array.isArray(argumentNodes)) return + + const keywords = argumentNodes[argumentNodes.length - 1] + + if (!isPrismNodeType(keywords, "KeywordHashNode")) return + + const { keyword } = this.descriptor + + for (const element of keywords.elements ?? []) { + if (!isPrismNodeType(element, "AssocNode")) continue + + const key = symbolKey(element.key) + + if (key === null) continue + + if (key === "data") { + this.checkDataHash(element.value) + } else if (keyword && key === keyword.name && !node.receiver && keyword.helpers.has(node.name)) { + this.keys.push(element.key) + } + } + } + + private checkDataHash(hash: PrismNode | null | undefined): void { + if (!isPrismNodeType(hash, "HashNode") && !isPrismNodeType(hash, "KeywordHashNode")) return + + for (const element of hash.elements ?? []) { + if (!isPrismNodeType(element, "AssocNode")) continue + if (symbolKey(element.key) !== this.descriptor.dataKey) continue + + this.keys.push(element.key) + } + } +} + +export class UJSAttributeVisitor extends BaseRuleVisitor { + constructor(private readonly descriptor: UJSAttributeDescriptor, ruleName: string, context?: Partial) { + super(ruleName, context) + } + + visitHTMLOpenTagNode(node: HTMLOpenTagNode): void { + this.checkAttributes(node.children) + + super.visitHTMLOpenTagNode(node) + } + + visitERBOpenTagNode(node: ERBOpenTagNode): void { + this.checkAttributes(node.children, true) + + super.visitERBOpenTagNode(node) + } + + visitERBContentNode(node: ERBContentNode): void { + this.checkHelperOptions(node.prismNode, node.source) + + super.visitERBContentNode(node) + } + + visitERBBlockNode(node: ERBBlockNode): void { + this.checkHelperOptions(node.prismNode, node.source) + + super.visitERBBlockNode(node) + } + + private checkAttributes(children: Node[] | null | undefined, fromHelper = false): void { + if (!children) return + + for (const attribute of filterHTMLAttributeNodes(children)) { + if (getAttributeName(attribute) !== this.descriptor.attribute) continue + + this.addOffense( + fromHelper ? optionMessage(this.descriptor) : attributeMessage(this.descriptor), + attribute.name!.location, + undefined, + undefined, + ["deprecated"], + ) + } + } + + private checkHelperOptions(prismNode: PrismNode | null | undefined, source: string | null | undefined): void { + if (!prismNode) return + if (!source) return + + const collector = new UJSOptionCollector(this.descriptor) + + collector.visit(prismNode) + + for (const key of collector.keys) { + const { startOffset, length } = key.location + + this.addOffense( + optionMessage(this.descriptor), + locationFromByteOffset(source, startOffset, length), + undefined, + undefined, + ["deprecated"], + ) + } + } +} diff --git a/javascript/packages/linter/src/rules/ujs-no-remote-attribute.ts b/javascript/packages/linter/src/rules/ujs-no-remote-attribute.ts new file mode 100644 index 000000000..dab75dfea --- /dev/null +++ b/javascript/packages/linter/src/rules/ujs-no-remote-attribute.ts @@ -0,0 +1,44 @@ +import { UJSAttributeVisitor } from "./ujs-base.js" +import { ParserRule } from "../types.js" + +import { helperNamesForTags } from "./action-view-utils.js" + +import type { UJSAttributeDescriptor } from "./ujs-base.js" +import type { ParseResult, ParserOptions } from "@herb-tools/core" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +const REMOTE_OPTION_HELPERS = helperNamesForTags("a", "form") + +const DESCRIPTOR: UJSAttributeDescriptor = { + attribute: "data-remote", + dataKey: "remote", + replacement: null, + keyword: { name: "remote", helpers: REMOTE_OPTION_HELPERS }, +} + +export class UJSNoRemoteAttributeRule extends ParserRule { + static ruleName = "ujs-no-remote-attribute" + static introducedIn = this.version("unreleased") + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "warning", + } + } + + get parserOptions(): Partial { + return { + action_view_helpers: true, + prism_nodes: true, + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new UJSAttributeVisitor(DESCRIPTOR, this.ruleName, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/ujs-prefer-turbo-confirm.ts b/javascript/packages/linter/src/rules/ujs-prefer-turbo-confirm.ts new file mode 100644 index 000000000..7a0cf2dcb --- /dev/null +++ b/javascript/packages/linter/src/rules/ujs-prefer-turbo-confirm.ts @@ -0,0 +1,39 @@ +import { UJSAttributeVisitor } from "./ujs-base.js" +import { ParserRule } from "../types.js" + +import type { UJSAttributeDescriptor } from "./ujs-base.js" +import type { ParseResult, ParserOptions } from "@herb-tools/core" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +const DESCRIPTOR: UJSAttributeDescriptor = { + attribute: "data-confirm", + dataKey: "confirm", + replacement: { attribute: "data-turbo-confirm", option: "data: { turbo_confirm: ... }" }, +} + +export class UJSPreferTurboConfirmRule extends ParserRule { + static ruleName = "ujs-prefer-turbo-confirm" + static introducedIn = this.version("unreleased") + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "warning", + } + } + + get parserOptions(): Partial { + return { + action_view_helpers: true, + prism_nodes: true, + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new UJSAttributeVisitor(DESCRIPTOR, this.ruleName, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/ujs-prefer-turbo-method.ts b/javascript/packages/linter/src/rules/ujs-prefer-turbo-method.ts new file mode 100644 index 000000000..8299a8df9 --- /dev/null +++ b/javascript/packages/linter/src/rules/ujs-prefer-turbo-method.ts @@ -0,0 +1,44 @@ +import { UJSAttributeVisitor } from "./ujs-base.js" +import { ParserRule } from "../types.js" + +import { helperNamesForTags } from "./action-view-utils.js" + +import type { UJSAttributeDescriptor } from "./ujs-base.js" +import type { ParseResult, ParserOptions } from "@herb-tools/core" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +const METHOD_OPTION_HELPERS = helperNamesForTags("a") + +const DESCRIPTOR: UJSAttributeDescriptor = { + attribute: "data-method", + dataKey: "method", + replacement: { attribute: "data-turbo-method", option: "data: { turbo_method: ... }" }, + keyword: { name: "method", helpers: METHOD_OPTION_HELPERS }, +} + +export class UJSPreferTurboMethodRule extends ParserRule { + static ruleName = "ujs-prefer-turbo-method" + static introducedIn = this.version("unreleased") + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "warning", + } + } + + get parserOptions(): Partial { + return { + action_view_helpers: true, + prism_nodes: true, + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new UJSAttributeVisitor(DESCRIPTOR, this.ruleName, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/ujs-prefer-turbo-submits-with.ts b/javascript/packages/linter/src/rules/ujs-prefer-turbo-submits-with.ts new file mode 100644 index 000000000..20b6b55cb --- /dev/null +++ b/javascript/packages/linter/src/rules/ujs-prefer-turbo-submits-with.ts @@ -0,0 +1,39 @@ +import { UJSAttributeVisitor } from "./ujs-base.js" +import { ParserRule } from "../types.js" + +import type { UJSAttributeDescriptor } from "./ujs-base.js" +import type { ParseResult, ParserOptions } from "@herb-tools/core" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" + +const DESCRIPTOR: UJSAttributeDescriptor = { + attribute: "data-disable-with", + dataKey: "disable_with", + replacement: { attribute: "data-turbo-submits-with", option: "data: { turbo_submits_with: ... }" }, +} + +export class UJSPreferTurboSubmitsWithRule extends ParserRule { + static ruleName = "ujs-prefer-turbo-submits-with" + static introducedIn = this.version("unreleased") + + get defaultConfig(): FullRuleConfig { + return { + enabled: true, + severity: "warning", + } + } + + get parserOptions(): Partial { + return { + action_view_helpers: true, + prism_nodes: true, + } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new UJSAttributeVisitor(DESCRIPTOR, this.ruleName, context) + + visitor.visit(result.value) + + return visitor.offenses + } +} diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index f4ea62bbb..5f554bc21 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -19,7 +19,7 @@ test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 124 enabled | all rules via --all-rules" + Rules 128 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > \`--only\` replaces the counts from a disabled \`all\` 1`] = ` @@ -50,7 +50,7 @@ exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` su Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 0 enabled | 124 not enabled + Rules 0 enabled | 128 not enabled No rules enabled: Every linter rule is turned off, so no offenses can be reported. @@ -71,7 +71,7 @@ exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` su Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 0 enabled | 124 not enabled + Rules 0 enabled | 128 not enabled No rules enabled: Every linter rule is turned off, so no offenses can be reported. @@ -102,7 +102,7 @@ test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 124 enabled" + Rules 128 enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > \`all: enabled: true\` reports no version-skipped rules 1`] = ` @@ -124,7 +124,7 @@ test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 124 enabled" + Rules 128 enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > reports enabled and not-enabled rules when no \`all\` is configured 1`] = ` @@ -146,7 +146,7 @@ test/fixtures/test-file-with-errors.html.erb: Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > rules opted back in on top of \`all: enabled: false\` count as enabled 1`] = ` @@ -165,7 +165,7 @@ test-file-with-errors.html.erb: Failing 0 offenses Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 1 enabled | 123 not enabled" + Rules 1 enabled | 127 not enabled" `; exports[`CLI Output Formatting > \`all\` pseudo rule in .herb.yml > \`Rules\` summary line > rules opted out on top of \`all: enabled: true\` count as disabled 1`] = ` @@ -184,7 +184,7 @@ test-file-with-errors.html.erb: Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 123 enabled | 1 disabled" + Rules 127 enabled | 1 disabled" `; exports[`CLI Output Formatting > --all-rules > reports every offense for the fixture with --all-rules 1`] = ` @@ -214,7 +214,7 @@ test/fixtures/all-rules.html.erb: Failing 0 offenses Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 124 enabled | all rules via --all-rules" + Rules 128 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > --all-rules > reports nothing for the fixture with the default rule set 1`] = ` @@ -226,7 +226,7 @@ exports[`CLI Output Formatting > --all-rules > reports nothing for the fixture w Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > --ignore-disable-comments 1`] = ` @@ -377,7 +377,7 @@ test/fixtures/ignored.html.erb:8:8 Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > --log-level > counts the hidden offenses and suggests the level that reveals them 1`] = ` @@ -395,7 +395,7 @@ exports[`CLI Output Formatting > --log-level > counts the hidden offenses and su Not failing 2 info | 1 hint (3 offenses across 1 file, below --fail-level=error) Not shown 3 offenses hidden, show them with --log-level=hint Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > --log-level > doesn't report offenses below the given level 1`] = ` @@ -412,7 +412,7 @@ exports[`CLI Output Formatting > --log-level > doesn't report offenses below the Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Not shown 1 offense hidden, show it with --log-level=hint Fixable 0 offenses - Rules 103 enabled | 20 not enabled | 1 disabled" + Rules 107 enabled | 20 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > prefers the CLI flag over the config file 1`] = ` @@ -431,7 +431,7 @@ test-file-with-errors.html.erb: Failing 0 offenses Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 103 enabled | 20 not enabled | 1 disabled" + Rules 107 enabled | 20 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > reads logLevel from the config file 1`] = ` @@ -448,7 +448,7 @@ exports[`CLI Output Formatting > --log-level > reads logLevel from the config fi Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Not shown 1 offense hidden, show it with --log-level=hint Fixable 0 offenses - Rules 103 enabled | 20 not enabled | 1 disabled" + Rules 107 enabled | 20 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > still counts hidden offenses towards the exit code 1`] = ` @@ -464,7 +464,7 @@ exports[`CLI Output Formatting > --log-level > still counts hidden offenses towa Offenses 1 hint (1 offense across 1 file) Not shown 1 offense hidden, show it with --log-level=hint Fixable 0 offenses - Rules 103 enabled | 20 not enabled | 1 disabled" + Rules 107 enabled | 20 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > still reports offenses at or above the given level 1`] = ` @@ -483,7 +483,7 @@ test-file-with-errors.html.erb: Failing 0 offenses Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Fixable 0 offenses - Rules 103 enabled | 20 not enabled | 1 disabled" + Rules 107 enabled | 20 not enabled | 1 disabled" `; exports[`CLI Output Formatting > --log-level > with --all-rules > keeps the log level when it is passed explicitly 1`] = ` @@ -505,7 +505,7 @@ test-file-with-errors.html.erb: Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Not shown 1 offense hidden, show it with --log-level=hint Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 124 enabled | all rules via --all-rules" + Rules 128 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > --log-level > with --all-rules > lowers the log level to report the rules it was asked for 1`] = ` @@ -528,7 +528,7 @@ test-file-with-errors.html.erb: Not failing 1 hint (1 offense across 1 file, below --fail-level=error) Log level hint | lowered from warning by --all-rules Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 124 enabled | all rules via --all-rules" + Rules 128 enabled | all rules via --all-rules" `; exports[`CLI Output Formatting > --log-level > with --only > keeps the log level when it is passed explicitly 1`] = ` @@ -689,7 +689,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -721,7 +721,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` @@ -826,7 +826,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4 Failing 4 errors (4 offenses across 1 file) Not failing 1 info (1 offense across 1 file, below --fail-level=error) Fixable 5 offenses | 3 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > Ignores disabled rules 1`] = ` @@ -886,7 +886,7 @@ test/fixtures/ignored.html.erb:6:14 Offenses 2 errors (2 offenses across 1 file) Ignored 3 offenses suppressed with herb:disable Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > allows tag.attributes in attribute position 1`] = ` @@ -946,7 +946,7 @@ test/fixtures/tag-attributes.html.erb:5:0 Failing 0 offenses Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = ` @@ -972,7 +972,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error (1 offense across 1 file) Fixable 0 offenses - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = ` @@ -1224,7 +1224,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:7 Failing 8 errors (8 offenses across 1 file) Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = ` @@ -1349,7 +1349,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Failing 4 errors (4 offenses across 1 file) Not failing 2 warnings (2 offenses across 1 file, below --fail-level=error) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = ` @@ -1404,7 +1404,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -1416,7 +1416,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -1495,7 +1495,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -1552,7 +1552,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = ` @@ -1599,7 +1599,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 104, + "ruleCount": 108, "totalErrors": 2, "totalHints": 0, "totalIgnored": 0, @@ -1621,7 +1621,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for clean file 1` "summary": { "filesChecked": 1, "filesWithOffenses": 0, - "ruleCount": 104, + "ruleCount": 108, "totalErrors": 0, "totalHints": 0, "totalIgnored": 0, @@ -1695,7 +1695,7 @@ exports[`CLI Output Formatting > formats JSON output correctly for file with err "summary": { "filesChecked": 1, "filesWithOffenses": 1, - "ruleCount": 104, + "ruleCount": 108, "totalErrors": 2, "totalHints": 0, "totalIgnored": 0, @@ -1778,7 +1778,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats simple output correctly 1`] = ` @@ -1797,7 +1797,7 @@ test/fixtures/test-file-simple.html.erb: Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = ` @@ -1816,7 +1816,7 @@ test/fixtures/bad-file.html.erb: Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > formats success output correctly 1`] = ` @@ -1828,7 +1828,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > handles boolean attributes 1`] = ` @@ -1840,7 +1840,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > handles multiple errors correctly 1`] = ` @@ -1891,7 +1891,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 1`] = ` @@ -2034,7 +2034,7 @@ test/fixtures/disabled-1.html.erb:14:19 Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Ignored 8 offenses suppressed with herb:disable Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 2`] = ` @@ -2293,7 +2293,7 @@ test/fixtures/disabled-2.html.erb:2:44 Not failing 7 warnings (7 offenses across 1 file, below --fail-level=error) Ignored 5 offenses suppressed with herb:disable Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > points at --log-level when many offenses don't fail the build 1`] = ` @@ -2331,7 +2331,7 @@ multiple-rule-offenses.html.erb: Failing 3 errors (3 offenses across 1 file) Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled + Rules 108 enabled | 20 not enabled TIP: 11 of the logged offenses don't fail the build. Run herb-lint --log-level=error to stop logging them, or set linter.logLevel in your .herb.yml. @@ -2363,7 +2363,7 @@ multiple-rule-offenses.html.erb: Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Not shown 11 offenses hidden, show them with --log-level=hint Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when --log-level is passed explicitly, even when it hides nothing 1`] = ` @@ -2401,7 +2401,7 @@ multiple-rule-offenses.html.erb: Failing 3 errors (3 offenses across 1 file) Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when logLevel is set in the config file 1`] = ` @@ -2439,7 +2439,7 @@ multiple-rule-offenses.html.erb: Failing 3 errors (3 offenses across 1 file) Not failing 1 info | 10 hints (11 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > non-failing offenses tip > stays quiet when only a handful of offenses don't fail the build 1`] = ` @@ -2477,7 +2477,7 @@ multiple-rule-offenses.html.erb: Failing 8 errors (8 offenses across 1 file) Not failing 6 warnings (6 offenses across 1 file, below --fail-level=error) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > points at the flag instead of previewing once there are too many corrections 1`] = ` @@ -2573,7 +2573,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > previews the correction under each correctable offense 1`] = ` @@ -2630,7 +2630,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is hint 1`] = ` @@ -2667,7 +2667,7 @@ test/fixtures/multiple-rule-offenses.html.erb: Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is info 1`] = ` @@ -2704,7 +2704,7 @@ test/fixtures/multiple-rule-offenses.html.erb: Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > groups every severity into one line when --fail-level is warning 1`] = ` @@ -2741,7 +2741,7 @@ test/fixtures/multiple-rule-offenses.html.erb: Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > moves a severity between buckets as --fail-level is lowered 1`] = ` @@ -2779,7 +2779,7 @@ multiple-rule-offenses.html.erb: Failing 8 errors | 4 warnings | 1 info (13 offenses across 1 file) Not failing 1 hint (1 offense across 1 file, below --fail-level=info) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > summary offense buckets > splits the buckets when only some severities fail the build 1`] = ` @@ -2817,7 +2817,7 @@ multiple-rule-offenses.html.erb: Failing 8 errors | 4 warnings (12 offenses across 1 file) Not failing 1 info | 1 hint (2 offenses across 1 file, below --fail-level=warning) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; exports[`CLI Output Formatting > unsafe autocorrectable offenses > counts and tags them separately from offenses --fix can correct 1`] = ` @@ -2937,5 +2937,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Failing 2 errors (2 offenses across 1 file) Not failing 1 warning (1 offense across 1 file, below --fail-level=error) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 104 enabled | 20 not enabled" + Rules 108 enabled | 20 not enabled" `; diff --git a/javascript/packages/linter/test/rules/ujs-no-remote-attribute.test.ts b/javascript/packages/linter/test/rules/ujs-no-remote-attribute.test.ts new file mode 100644 index 000000000..c8ecab99e --- /dev/null +++ b/javascript/packages/linter/test/rules/ujs-no-remote-attribute.test.ts @@ -0,0 +1,120 @@ +import { beforeAll, describe, expect, test } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" + +import { UJSNoRemoteAttributeRule } from "../../src/rules/ujs-no-remote-attribute.js" +import { Linter } from "../../src/linter.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(UJSNoRemoteAttributeRule) + +const ATTRIBUTE_MESSAGE = "Avoid the deprecated `@rails/ujs` attribute `data-remote`. Turbo handles links and form submissions by default, so it can be removed." +const OPTION_MESSAGE = "Avoid the deprecated `@rails/ujs` option, which renders `data-remote`. Turbo handles links and form submissions by default, so it can be removed." + +describe("ujs-no-remote-attribute", () => { + describe("HTML attributes", () => { + test("passes when the attribute is absent", () => { + expectNoOffenses(`Load posts`) + }) + + test("passes for a near-miss attribute name", () => { + expectNoOffenses(`Load posts`) + }) + + test("passes for the attributes owned by the sibling rules", () => { + expectNoOffenses(`Delete`) + }) + + test("fails for `data-remote`", () => { + expectWarning(ATTRIBUTE_MESSAGE, { line: 1, column: 17 }) + + assertOffenses(`Load posts`) + }) + + test("fails for a value-less attribute", () => { + expectWarning(ATTRIBUTE_MESSAGE) + + assertOffenses(`Load posts`) + }) + }) + + describe("Action View helpers", () => { + test("passes without the option", () => { + expectNoOffenses(`<%= link_to "Load posts", posts_path %>`) + }) + + test("fails for the `remote:` option on `link_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to "Load posts", posts_path, remote: true %>`) + }) + + test("fails for the `data: { remote: ... }` option on `link_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to "Load posts", posts_path, data: { remote: true } %>`) + }) + + test("fails for the `remote:` option on `form_with`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= form_with model: @post, remote: true do |f| %><% end %>`) + }) + + test("fails for the `remote:` option on `mail_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= mail_to "a@b.com", "Mail", remote: true %>`) + }) + + // Unlike `method:`, `remote:` applied to real forms too, so the helper set here + // is derived from every Action View helper rendering an `` or a `
`. + test("fails for the `remote:` option on `button_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= button_to "Delete", post_path(@post), remote: true %>`) + }) + + test("fails for the `remote:` option on `form_tag`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= form_tag "/posts", remote: true do %><% end %>`) + }) + + test("fails for `data: { remote: ... }` on `tag.a`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= tag.a "Load posts", href: posts_path, data: { remote: true } %>`) + }) + + test("passes for a `remote:` keyword on a non-helper call", () => { + expectNoOffenses(`<%= presenter.build(remote: true) %>`) + }) + + test("passes for a `data:` hash nested in another option", () => { + expectNoOffenses(`<%= render "form", locals: { data: { remote: true } } %>`) + }) + }) + + describe("with the raw linter", () => { + beforeAll(async () => { + await Herb.load() + }) + + test("tags offenses as deprecated", () => { + const linter = new Linter(Herb, [UJSNoRemoteAttributeRule]) + const result = linter.lint(`Load posts`) + + expect(result.offenses).toHaveLength(1) + expect(result.offenses[0].tags).toEqual(["deprecated"]) + expect(result.offenses[0].severity).toBe("warning") + }) + + test("reports the option only once", () => { + const linter = new Linter(Herb, [UJSNoRemoteAttributeRule]) + const result = linter.lint(`<%= link_to "Load posts", posts_path, remote: true %>`) + + expect(result.offenses).toHaveLength(1) + }) + }) +}) diff --git a/javascript/packages/linter/test/rules/ujs-prefer-turbo-confirm.test.ts b/javascript/packages/linter/test/rules/ujs-prefer-turbo-confirm.test.ts new file mode 100644 index 000000000..b3a4e0938 --- /dev/null +++ b/javascript/packages/linter/test/rules/ujs-prefer-turbo-confirm.test.ts @@ -0,0 +1,90 @@ +import { beforeAll, describe, expect, test } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" + +import { UJSPreferTurboConfirmRule } from "../../src/rules/ujs-prefer-turbo-confirm.js" +import { Linter } from "../../src/linter.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(UJSPreferTurboConfirmRule) + +const ATTRIBUTE_MESSAGE = "Avoid the deprecated `@rails/ujs` attribute `data-confirm`. Use `data-turbo-confirm` instead." +const OPTION_MESSAGE = "Avoid the deprecated `@rails/ujs` option, which renders `data-confirm`. Use `data: { turbo_confirm: ... }` instead." + +describe("ujs-prefer-turbo-confirm", () => { + describe("HTML attributes", () => { + test("passes for the Turbo equivalent", () => { + expectNoOffenses(`Delete`) + }) + + test("passes for a near-miss attribute name", () => { + expectNoOffenses(`Delete`) + }) + + test("passes for the attributes owned by the sibling rules", () => { + expectNoOffenses(`Delete`) + }) + + test("fails for `data-confirm`", () => { + expectWarning(ATTRIBUTE_MESSAGE, { line: 1, column: 19 }) + + assertOffenses(`Delete`) + }) + + test("fails for an uppercase attribute name", () => { + expectWarning(ATTRIBUTE_MESSAGE) + + assertOffenses(`Delete`) + }) + }) + + describe("Action View helpers", () => { + test("passes for the Turbo equivalent", () => { + expectNoOffenses(`<%= link_to "Delete", post_path(@post), data: { turbo_confirm: "Are you sure?" } %>`) + }) + + test("fails for `data: { confirm: ... }` on `link_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to "Delete", post_path(@post), data: { confirm: "Are you sure?" } %>`) + }) + + test("fails for `data: { confirm: ... }` on `button_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= button_to "Delete", post_path(@post), data: { confirm: "Are you sure?" } %>`) + }) + + test("fails for a dynamic value", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to "Delete", post_path(@post), data: { confirm: t(".sure") } %>`) + }) + + test("passes for a `data:` hash nested in another option", () => { + expectNoOffenses(`<%= render "form", locals: { data: { confirm: "Are you sure?" } } %>`) + }) + }) + + describe("with the raw linter", () => { + beforeAll(async () => { + await Herb.load() + }) + + test("tags offenses as deprecated", () => { + const linter = new Linter(Herb, [UJSPreferTurboConfirmRule]) + const result = linter.lint(`Delete`) + + expect(result.offenses).toHaveLength(1) + expect(result.offenses[0].tags).toEqual(["deprecated"]) + expect(result.offenses[0].severity).toBe("warning") + }) + + test("reports the option only once", () => { + const linter = new Linter(Herb, [UJSPreferTurboConfirmRule]) + const result = linter.lint(`<%= link_to "Delete", post_path(@post), data: { confirm: "Are you sure?" } %>`) + + expect(result.offenses).toHaveLength(1) + }) + }) +}) diff --git a/javascript/packages/linter/test/rules/ujs-prefer-turbo-method.test.ts b/javascript/packages/linter/test/rules/ujs-prefer-turbo-method.test.ts new file mode 100644 index 000000000..d7c87ee5e --- /dev/null +++ b/javascript/packages/linter/test/rules/ujs-prefer-turbo-method.test.ts @@ -0,0 +1,149 @@ +import { beforeAll, describe, expect, test } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" + +import { UJSPreferTurboConfirmRule } from "../../src/rules/ujs-prefer-turbo-confirm.js" +import { UJSPreferTurboMethodRule } from "../../src/rules/ujs-prefer-turbo-method.js" +import { Linter } from "../../src/linter.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(UJSPreferTurboMethodRule) + +const ATTRIBUTE_MESSAGE = "Avoid the deprecated `@rails/ujs` attribute `data-method`. Use `data-turbo-method` instead." +const OPTION_MESSAGE = "Avoid the deprecated `@rails/ujs` option, which renders `data-method`. Use `data: { turbo_method: ... }` instead." + +describe("ujs-prefer-turbo-method", () => { + describe("HTML attributes", () => { + test("passes for elements without deprecated attributes", () => { + expectNoOffenses(`Delete`) + }) + + test("passes for the Turbo equivalent", () => { + expectNoOffenses(`Delete`) + }) + + test("passes for a near-miss attribute name", () => { + expectNoOffenses(`Delete`) + }) + + test("passes for the attributes owned by the sibling rules", () => { + expectNoOffenses(`Delete`) + }) + + test("fails for `data-method`", () => { + expectWarning(ATTRIBUTE_MESSAGE, { line: 1, column: 19 }) + + assertOffenses(`Delete`) + }) + }) + + describe("`link_to` helper", () => { + test("passes for the Turbo equivalent", () => { + expectNoOffenses(`<%= link_to "Delete", post_path(@post), data: { turbo_method: :delete } %>`) + }) + + test("fails for the `method:` option", () => { + expectWarning(OPTION_MESSAGE, { line: 1, column: 40 }) + + assertOffenses(`<%= link_to "Delete", post_path(@post), method: :delete %>`) + }) + + test("fails for the `data: { method: ... }` option", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to "Delete", post_path(@post), data: { method: :delete } %>`) + }) + + test("fails for the block form", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to post_path(@post), method: :delete do %>Delete<% end %>`) + }) + + test("fails for `link_to_if`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= link_to_if cond, "Delete", post_path(@post), method: :delete %>`) + }) + + test("fails for `mail_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= mail_to "support@example.com", "Mail", method: :post %>`) + }) + + // `phone_to` and `sms_to` come from deriving the helper set off the Action View + // registry rather than hardcoding it. They pass `html_options` straight through + // to `link_to`, so a `method:` option on them renders `data-method` too. + test("fails for `phone_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= phone_to "555-1234", "Call", method: :post %>`) + }) + + test("fails for `sms_to`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= sms_to "555-1234", "Text", method: :post %>`) + }) + }) + + describe("other Action View helpers", () => { + test("passes for the `method:` option on `button_to`, which renders a real form", () => { + expectNoOffenses(`<%= button_to "Delete", post_path(@post), method: :delete %>`) + }) + + test("passes for the `method:` option on `form_with`", () => { + expectNoOffenses(`<%= form_with model: @post, method: :patch do |f| %><% end %>`) + }) + + test("passes for a `method:` keyword on a non-helper call", () => { + expectNoOffenses(`<%= presenter.build(method: :delete) %>`) + }) + + test("passes for a `data:` hash nested in another option", () => { + expectNoOffenses(`<%= render "form", locals: { data: { method: :delete } } %>`) + }) + }) + + describe("with the raw linter", () => { + beforeAll(async () => { + await Herb.load() + }) + + test("tags offenses as deprecated", () => { + const linter = new Linter(Herb, [UJSPreferTurboMethodRule]) + const result = linter.lint(`Delete`) + + expect(result.offenses).toHaveLength(1) + expect(result.offenses[0].tags).toEqual(["deprecated"]) + expect(result.offenses[0].severity).toBe("warning") + }) + + test("reports the `method:` option only once", () => { + const linter = new Linter(Herb, [UJSPreferTurboMethodRule]) + const result = linter.lint(`<%= link_to "Delete", post_path(@post), method: :delete %>`) + + expect(result.offenses).toHaveLength(1) + }) + + test("reports the `data: { method: ... }` option only once", () => { + const linter = new Linter(Herb, [UJSPreferTurboMethodRule]) + const result = linter.lint(`<%= link_to "Delete", post_path(@post), data: { method: :delete } %>`) + + expect(result.offenses).toHaveLength(1) + }) + + test("each sibling UJS rule reports its own attribute", () => { + const linter = new Linter(Herb, [UJSPreferTurboMethodRule, UJSPreferTurboConfirmRule]) + const result = linter.lint(`Delete`) + + const byRule = Object.fromEntries(result.offenses.map(offense => [offense.rule, offense])) + + expect(result.offenses).toHaveLength(2) + + expect(byRule["ujs-prefer-turbo-confirm"].location.start.column).toBe(19) + expect(byRule["ujs-prefer-turbo-method"].location.start.column).toBe(48) + }) + }) +}) diff --git a/javascript/packages/linter/test/rules/ujs-prefer-turbo-submits-with.test.ts b/javascript/packages/linter/test/rules/ujs-prefer-turbo-submits-with.test.ts new file mode 100644 index 000000000..fc4f0c8c2 --- /dev/null +++ b/javascript/packages/linter/test/rules/ujs-prefer-turbo-submits-with.test.ts @@ -0,0 +1,85 @@ +import { beforeAll, describe, expect, test } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" + +import { UJSPreferTurboSubmitsWithRule } from "../../src/rules/ujs-prefer-turbo-submits-with.js" +import { Linter } from "../../src/linter.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(UJSPreferTurboSubmitsWithRule) + +const ATTRIBUTE_MESSAGE = "Avoid the deprecated `@rails/ujs` attribute `data-disable-with`. Use `data-turbo-submits-with` instead." +const OPTION_MESSAGE = "Avoid the deprecated `@rails/ujs` option, which renders `data-disable-with`. Use `data: { turbo_submits_with: ... }` instead." + +describe("ujs-prefer-turbo-submits-with", () => { + describe("HTML attributes", () => { + test("passes for the Turbo equivalent", () => { + expectNoOffenses(``) + }) + + test("passes for a near-miss attribute name", () => { + expectNoOffenses(``) + }) + + test("passes for the attributes owned by the sibling rules", () => { + expectNoOffenses(`Delete`) + }) + + test("fails for `data-disable-with`", () => { + expectWarning(ATTRIBUTE_MESSAGE, { line: 1, column: 8 }) + + assertOffenses(``) + }) + }) + + describe("Action View helpers", () => { + test("passes for the Turbo equivalent", () => { + expectNoOffenses(`<%= f.submit "Save", data: { turbo_submits_with: "Saving..." } %>`) + }) + + test("fails for `data: { disable_with: ... }` on a form builder", () => { + expectWarning(OPTION_MESSAGE, { line: 2, column: 31 }) + + assertOffenses(`<%= form_with model: @post do |f| %>\n <%= f.submit "Save", data: { disable_with: "Saving..." } %>\n<% end %>`) + }) + + test("fails for `data: { disable_with: ... }` on `submit_tag`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= submit_tag "Save", data: { disable_with: "Saving..." } %>`) + }) + + test("fails for `data: { disable_with: ... }` on `button_tag`", () => { + expectWarning(OPTION_MESSAGE) + + assertOffenses(`<%= button_tag "Save", data: { disable_with: "Saving..." } %>`) + }) + + test("passes for a `data:` hash nested in another option", () => { + expectNoOffenses(`<%= render "form", locals: { data: { disable_with: "Saving..." } } %>`) + }) + }) + + describe("with the raw linter", () => { + beforeAll(async () => { + await Herb.load() + }) + + test("tags offenses as deprecated", () => { + const linter = new Linter(Herb, [UJSPreferTurboSubmitsWithRule]) + const result = linter.lint(``) + + expect(result.offenses).toHaveLength(1) + expect(result.offenses[0].tags).toEqual(["deprecated"]) + expect(result.offenses[0].severity).toBe("warning") + }) + + test("tags helper option offenses as deprecated", () => { + const linter = new Linter(Herb, [UJSPreferTurboSubmitsWithRule]) + const result = linter.lint(`<%= submit_tag "Save", data: { disable_with: "Saving..." } %>`) + + expect(result.offenses).toHaveLength(1) + expect(result.offenses[0].tags).toEqual(["deprecated"]) + }) + }) +})