Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 testCaseNameDuplicates from "./rules/testCaseNameDuplicates.ts";
import testShorthands from "./rules/testShorthands.ts";
Expand All @@ -17,6 +18,7 @@ export const flint = createPlugin({
testCaseDuplicates,
testCaseNameDuplicates,
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 plugins that are only intended for internal use or tooling that
will need to call `language.createRule()` directly, you can safely disable this rule.