Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/crisp-foxes-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@flint.fyi/plugin-flint": minor
---

Add `ruleCreationMethods` rule
8 changes: 8 additions & 0 deletions packages/comparisons/src/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,14 @@
"status": "skipped"
}
},
{
"flint": {
"name": "ruleCreationMethods",
"plugin": "flint",
"preset": "logical",
"status": "implemented"
}
},
{
"eslint": [
{
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/plugins/createPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import z from "zod/v4";

import { createLanguage } from "../languages/createLanguage.ts";
import { RuleCreator } from "../rules/RuleCreator.ts";
import { createPlugin } from "./createPlugin.ts";

const stubLanguage = createLanguage({
Expand All @@ -12,7 +13,13 @@ const stubLanguage = createLanguage({

const stubMessages = { "": { primary: "", secondary: [], suggestions: [] } };

const ruleStandalone = stubLanguage.createRule({
const ruleCreator = new RuleCreator({
docs: (ruleId) => `https://flint.fyi/rules/stub/${ruleId.toLowerCase()}`,
pluginId: "stub",
presets: ["first", "second"],
});

const ruleStandalone = ruleCreator.createRule(stubLanguage, {
about: {
description: "",
id: "standalone",
Expand All @@ -22,7 +29,7 @@ const ruleStandalone = stubLanguage.createRule({
setup: vi.fn(),
});

const ruleWithOptionalOption = stubLanguage.createRule({
const ruleWithOptionalOption = ruleCreator.createRule(stubLanguage, {
about: {
description: "",
id: "withOptionalOption",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/rules/RuleCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export class RuleCreator<Presets extends string> {
MessageId,
OptionsSchema
> {
// Use RuleCreator.createRule instead of Language.createRule
// But this is the original implementation
Comment on lines +49 to +50
Copy link
Member

@lishaduck lishaduck Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just wanna say this is 🔥 lol.

Actually, TBH maybe we should ignore this rule for the core package specifically, because it shouldn't be defining any rules? No, that depends on how we structure #2181. Though, having said that, we probably should structure it out-of-core. Ehh, who cares?

// flint-disable-next-line flint/ruleCreationMethods
return language.createRule({
...rule,
about: {
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-flint/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import invalidCodeLines from "./rules/invalidCodeLines.ts";
import missingPlaceholders from "./rules/missingPlaceholders.ts";
import nodePropertyInChecks from "./rules/nodePropertyInChecks.ts";
import placeholderFormats from "./rules/placeholderFormats.ts";
import ruleCreationMethods from "./rules/ruleCreationMethods.ts";
import testCaseDuplicates from "./rules/testCaseDuplicates.ts";
import testShorthands from "./rules/testShorthands.ts";

Expand All @@ -15,6 +16,7 @@ export const flint = createPlugin({
invalidCodeLines,
testCaseDuplicates,
missingPlaceholders,
ruleCreationMethods,
placeholderFormats,
testShorthands,
nodePropertyInChecks,
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-flint/src/rules/getStartSourceFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
import { SyntaxKind } from "typescript";

import { isTSNode } from "../utils/isTSNode.ts";
import { ruleCreator } from "./ruleCreator.ts";

export default typescriptLanguage.createRule({
export default ruleCreator.createRule(typescriptLanguage, {
about: {
description:
"Requires passing `sourceFile` to `getStart()` for better performance.",
Expand Down
50 changes: 50 additions & 0 deletions packages/plugin-flint/src/rules/ruleCreationMethods.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import rule from "./ruleCreationMethods.ts";
import { ruleTester } from "./ruleTester.ts";

ruleTester.describe(rule, {
invalid: [
{
code: `
export default typescriptLanguage.createRule({
about: {
description: "Test rule",
id: "testRule",
presets: ["logical"],
},
messages: {},
setup(context) {
return { visitors: {} };
},
});
`,
snapshot: `
export default typescriptLanguage.createRule({
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Plugin rules should be created through RuleCreator instead of calling language.createRule() directly.
about: {
description: "Test rule",
id: "testRule",
presets: ["logical"],
},
messages: {},
setup(context) {
return { visitors: {} };
},
});
`,
},
],
valid: [
`
interface RuleCreator { createRule<T>(language: any, ruleConfig: { messages: Record<string, string> }): T; }
declare const ruleCreator: RuleCreator;

export default ruleCreator.createRule(_, {
messages: {},
setup(context) {
return { visitors: {} };
}
});
`,
],
});
45 changes: 45 additions & 0 deletions packages/plugin-flint/src/rules/ruleCreationMethods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
getTSNodeRange,
typescriptLanguage,
} from "@flint.fyi/typescript-language";

import { isLanguageCreateRule } from "../utils/ruleCreatorHelpers.ts";
import { ruleCreator } from "./ruleCreator.ts";

export default ruleCreator.createRule(typescriptLanguage, {
about: {
description:
"Reports plugin rules created directly with language instead of through RuleCreator.",
id: "ruleCreationMethods",
presets: ["logical"],
},
messages: {
ruleCreationMethods: {
primary:
"Plugin rules should be created through RuleCreator instead of calling language.createRule() directly.",
secondary: [
"Direct language creation bypasses the standardized rule metadata and documentation provided by RuleCreator.",
"RuleCreator adds the `docs` property and ensures consistent rule structure across plugins.",
],
suggestions: [
"Create an instance of RuleCreator from `@flint.fyi/core` (e.g. `const ruleCreator = new RuleCreator({ presets: [...] })`), then use `ruleCreator.createRule(language, { ... })` instead of `language.createRule({ ... })`.",
],
},
},
setup(context) {
return {
visitors: {
CallExpression(node, { sourceFile, typeChecker }) {
if (!isLanguageCreateRule(node, typeChecker)) {
return;
}

context.report({
message: "ruleCreationMethods",
range: getTSNodeRange(node.expression, sourceFile),
});
},
},
};
},
});
30 changes: 20 additions & 10 deletions packages/plugin-flint/src/utils/ruleCreatorHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,33 @@ export function getStringOriginalQuote(
return text[0] ?? '"';
}

export function isLanguageCreateRule(
node: AST.CallExpression,
typeChecker: ts.TypeChecker,
): boolean {
return (
node.expression.kind === SyntaxKind.PropertyAccessExpression &&
node.expression.name.text === "createRule" &&
!isRuleCreatorCreateRule(node, typeChecker)
);
}

// TODO: Maybe need to check it more strictly
// https://github.com/flint-fyi/flint/issues/152
export function isRuleCreatorCreateRule(
node: AST.CallExpression,
typeChecker: ts.TypeChecker,
): boolean {
if (node.expression.kind !== SyntaxKind.PropertyAccessExpression) {
return false;
}

const propertyAccess = node.expression;
const type = typeChecker.getTypeAtLocation(propertyAccess.expression);
const typeName = type.getSymbol()?.getName();
if (
node.expression.kind === SyntaxKind.PropertyAccessExpression &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I was suggesting something like this

const isPropertyAccessExpression = (node: : AST.CallExpression): node is PropertyAccessExpression {
  return node.expression.kind === SyntaxKind.PropertyAccessExpression;
}

const isCreateRuleCall = (node: : AST.CallExpression): boolean {
  return isPropertyAccessExpression(node) && node.expression.name.text === "createRule";
}

But this works fine too. 👍

node.expression.name.text === "createRule"
) {
const propertyAccess = node.expression;
const type = typeChecker.getTypeAtLocation(propertyAccess.expression);

return (
typeName === "RuleCreator" && propertyAccess.name.text === "createRule"
);
return type.getSymbol()?.getName() === "RuleCreator";
}
return false;
}

// TODO: Maybe need to check it more strictly
Expand Down
67 changes: 67 additions & 0 deletions packages/site/src/content/docs/rules/flint/ruleCreationMethods.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
description: "Reports plugin rules created directly with language instead of through RuleCreator."
title: "ruleCreationMethods"
topic: "rules"
---

import { Tabs, TabItem } from "@astrojs/starlight/components";
import { RuleEquivalents } from "~/components/RuleEquivalents";
import RuleSummary from "~/components/RuleSummary.astro";

<RuleSummary plugin="flint" rule="ruleCreationMethods" />

Plugin rules should be created through `RuleCreator` instead of calling `language.createRule()` directly.
Direct language creation bypasses the standardized rule metadata and documentation provided by `RuleCreator`, which adds the `docs` property and ensures consistent rule structure across plugins.

## Examples

<Tabs>
<TabItem label="❌ Incorrect">

```ts
import { typescriptLanguage } from "@flint.fyi/typescript-language";

export default typescriptLanguage.createRule({
about: {
description: "My rule",
id: "myRule",
presets: ["logical"],
},
setup(context) {
return { visitors: {} };
},
});
```

</TabItem>
<TabItem label="✅ Correct">

```ts
import { typescriptLanguage } from "@flint.fyi/typescript-language";
import { RuleCreator } from "@flint.fyi/core";

const ruleCreator = new RuleCreator({ presets: ["logical"] });

export default ruleCreator.createRule(typescriptLanguage, {
about: {
description: "My rule",
id: "myRule",
presets: ["logical"],
},
setup(context) {
return { visitors: {} };
},
});
```

</TabItem>
</Tabs>

## Options

This rule is not configurable.

## When Not To Use It

If you are developing internal plugins or core packages that need to call `language.createRule()` directly,
such as the `RuleCreator` implementation itself, you can safely disable this rule.