/**"],
20 | "type": "node"
21 | }
22 | ],
23 | "version": "0.2.0"
24 | }
25 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "editor.formatOnSave": true,
5 | "editor.rulers": [80],
6 | "eslint.probe": [
7 | "javascript",
8 | "javascriptreact",
9 | "json",
10 | "jsonc",
11 | "markdown",
12 | "typescript",
13 | "typescriptreact",
14 | "yaml"
15 | ],
16 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
17 | "typescript.tsdk": "node_modules/typescript/lib"
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "tasks": [
3 | {
4 | "detail": "Build the project",
5 | "label": "build",
6 | "script": "build",
7 | "type": "npm"
8 | }
9 | ],
10 | "version": "2.0.0"
11 | }
12 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | # [0.3.0](https://github.com/JoshuaKGoldberg/flint/compare/0.2.0...0.3.0) (2025-05-22)
4 |
5 | ### Features
6 |
7 | - rules with options or type checking, with passing unit tests ([e6aa4f5](https://github.com/JoshuaKGoldberg/flint/commit/e6aa4f5dadb27dccbd89499049a54fd8d5915f51))
8 |
9 | # 0.2.0 (2025-05-22)
10 |
11 | ### Bug Fixes
12 |
13 | - add bin and correct README.md from template ([6a0eefa](https://github.com/JoshuaKGoldberg/flint/commit/6a0eefa3e8e625704b0bb547bf5c83512388974f))
14 | - bump package to 0.1.0, so it'll publish as 0.1.1 ([84014be](https://github.com/JoshuaKGoldberg/flint/commit/84014beb2ab4da1fc7b23cb8a0fc113bbbcb5c52))
15 |
16 | ### Features
17 |
18 | - initialized repo ✨ ([f411df5](https://github.com/JoshuaKGoldberg/flint/commit/f411df5890399bc62e1794e6839562e6c1bd131d))
19 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | 'Software'), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Flint
2 |
3 |
4 | A fast, friendly linter.
5 | ❤️🔥
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | **Flint** is an experimental new linter.
24 | It's a proof-of-concept to explore the concepts in the following blog posts:
25 |
26 | - [Hybrid Linters: The Best of Both Worlds](https://www.joshuakgoldberg.com/blog/hybrid-linters-the-best-of-both-worlds)
27 | - [If I Wrote a Linter, Part 1: Architecture](https://www.joshuakgoldberg.com/blog/if-i-wrote-a-linter-part-1-architecture)
28 | - [If I Wrote a Linter, Part 2: Developer Experience](https://www.joshuakgoldberg.com/blog/if-i-wrote-a-linter-part-2-developer-experience)
29 | - [If I Wrote a Linter, Part 3: Ecosystem](https://www.joshuakgoldberg.com/blog/if-i-wrote-a-linter-part-3-ecosystem)
30 |
31 | This project might go nowhere.
32 | It might show some of those ideas to be wrong.
33 | It might become a real linter.
34 | Only time will tell.
35 |
36 | ## Usage
37 |
38 | Coming soon.
39 |
40 | ## Development
41 |
42 | See [`.github/CONTRIBUTING.md`](./.github/CONTRIBUTING.md), then [`.github/DEVELOPMENT.md`](./.github/DEVELOPMENT.md).
43 | Thanks! ❤️🔥
44 |
45 | ## Contributors
46 |
47 |
48 |
49 |
50 |
51 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | > 💝 This package was templated with [`create-typescript-app`](https://github.com/JoshuaKGoldberg/create-typescript-app) using the [Bingo framework](https://create.bingo).
66 |
--------------------------------------------------------------------------------
/bin/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | console.log("Hello, world!");
3 |
--------------------------------------------------------------------------------
/cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | "dictionaries": ["npm", "node", "typescript"],
3 | "ignorePaths": [
4 | ".all-contributorsrc",
5 | ".github",
6 | "CHANGELOG.md",
7 | "coverage",
8 | "lib",
9 | "node_modules",
10 | "pnpm-lock.yaml"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/docs/flint.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JoshuaKGoldberg/flint/ac6ccffce4ef3490be1e021af69510f725976618/docs/flint.png
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import comments from "@eslint-community/eslint-plugin-eslint-comments/configs";
2 | import eslint from "@eslint/js";
3 | import vitest from "@vitest/eslint-plugin";
4 | import jsdoc from "eslint-plugin-jsdoc";
5 | import jsonc from "eslint-plugin-jsonc";
6 | import markdown from "eslint-plugin-markdown";
7 | import n from "eslint-plugin-n";
8 | import packageJson from "eslint-plugin-package-json";
9 | import perfectionist from "eslint-plugin-perfectionist";
10 | import * as regexp from "eslint-plugin-regexp";
11 | import yml from "eslint-plugin-yml";
12 | import tseslint from "typescript-eslint";
13 |
14 | export default tseslint.config(
15 | {
16 | ignores: ["**/*.snap", "coverage", "lib", "node_modules", "pnpm-lock.yaml"],
17 | },
18 | { linterOptions: { reportUnusedDisableDirectives: "error" } },
19 | eslint.configs.recommended,
20 | comments.recommended,
21 | jsdoc.configs["flat/contents-typescript-error"],
22 | jsdoc.configs["flat/logical-typescript-error"],
23 | jsdoc.configs["flat/stylistic-typescript-error"],
24 | jsonc.configs["flat/recommended-with-json"],
25 | markdown.configs.recommended,
26 | n.configs["flat/recommended"],
27 | packageJson.configs.recommended,
28 | perfectionist.configs["recommended-natural"],
29 | regexp.configs["flat/recommended"],
30 | {
31 | extends: [
32 | tseslint.configs.strictTypeChecked,
33 | tseslint.configs.stylisticTypeChecked,
34 | ],
35 | files: ["**/*.{js,ts}"],
36 | languageOptions: {
37 | parserOptions: {
38 | projectService: {
39 | allowDefaultProject: ["*.config.*s", "bin/index.js"],
40 | },
41 | tsconfigRootDir: import.meta.dirname,
42 | },
43 | },
44 | rules: {
45 | // Stylistic concerns that don't interfere with Prettier
46 | "logical-assignment-operators": [
47 | "error",
48 | "always",
49 | { enforceForIfStatements: true },
50 | ],
51 | "no-useless-rename": "error",
52 | "object-shorthand": "error",
53 | "operator-assignment": "error",
54 | },
55 | settings: {
56 | perfectionist: { partitionByComment: true, type: "natural" },
57 | vitest: { typecheck: true },
58 | },
59 | },
60 | {
61 | extends: [tseslint.configs.disableTypeChecked],
62 | files: ["**/*.md/*.ts"],
63 | rules: { "n/no-missing-import": "off" },
64 | },
65 | {
66 | extends: [vitest.configs.recommended],
67 | files: ["**/*.test.*"],
68 | rules: { "@typescript-eslint/no-unsafe-assignment": "off" },
69 | },
70 | {
71 | extends: [yml.configs["flat/standard"], yml.configs["flat/prettier"]],
72 | files: ["**/*.{yml,yaml}"],
73 | rules: {
74 | "yml/file-extension": ["error", { extension: "yml" }],
75 | "yml/sort-keys": [
76 | "error",
77 | { order: { type: "asc" }, pathPattern: "^.*$" },
78 | ],
79 | "yml/sort-sequence-values": [
80 | "error",
81 | { order: { type: "asc" }, pathPattern: "^.*$" },
82 | ],
83 | },
84 | },
85 | );
86 |
--------------------------------------------------------------------------------
/knip.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/knip@5.46.0/schema.json",
3 | "entry": ["src/**/*.test.*", "src/index.ts"],
4 | "ignoreExportsUsedInFile": { "interface": true, "type": true },
5 | "project": ["src/**/*.ts"]
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "flint",
3 | "version": "0.3.0",
4 | "description": "A fast, friendly linter. ❤️🔥",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/JoshuaKGoldberg/flint.git"
8 | },
9 | "license": "MIT",
10 | "author": {
11 | "name": "Josh Goldberg ✨",
12 | "email": "npm@joshuakgoldberg.com"
13 | },
14 | "type": "module",
15 | "main": "lib/index.js",
16 | "bin": "bin/index.js",
17 | "files": [
18 | "LICENSE.md",
19 | "README.md",
20 | "bin/index.js",
21 | "lib/",
22 | "package.json"
23 | ],
24 | "scripts": {
25 | "build": "tsup",
26 | "format": "prettier .",
27 | "lint": "eslint . --max-warnings 0",
28 | "lint:knip": "knip",
29 | "lint:md": "markdownlint \"**/*.md\" \".github/**/*.md\" --rules sentences-per-line",
30 | "lint:packages": "pnpm dedupe --check",
31 | "lint:spelling": "cspell \"**\" \".github/**/*\"",
32 | "prepare": "husky",
33 | "test": "vitest --typecheck",
34 | "tsc": "tsc"
35 | },
36 | "lint-staged": {
37 | "*": "prettier --ignore-unknown --write"
38 | },
39 | "dependencies": {
40 | "@typescript/vfs": "^1.6.1",
41 | "cached-factory": "^0.1.0",
42 | "ts-api-utils": "^2.1.0",
43 | "zod": "^3.25.20"
44 | },
45 | "devDependencies": {
46 | "@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
47 | "@eslint/js": "9.27.0",
48 | "@release-it/conventional-changelog": "10.0.0",
49 | "@types/eslint-plugin-markdown": "2.0.2",
50 | "@types/node": "22.15.18",
51 | "@vitest/coverage-v8": "3.1.3",
52 | "@vitest/eslint-plugin": "1.2.0",
53 | "console-fail-test": "0.5.0",
54 | "cspell": "9.0.1",
55 | "eslint": "9.27.0",
56 | "eslint-plugin-jsdoc": "50.6.8",
57 | "eslint-plugin-jsonc": "2.20.0",
58 | "eslint-plugin-markdown": "5.1.0",
59 | "eslint-plugin-n": "17.18.0",
60 | "eslint-plugin-package-json": "0.31.0",
61 | "eslint-plugin-perfectionist": "4.13.0",
62 | "eslint-plugin-regexp": "2.7.0",
63 | "eslint-plugin-yml": "1.18.0",
64 | "husky": "9.1.7",
65 | "knip": "5.58.0",
66 | "lint-staged": "16.0.0",
67 | "markdownlint": "0.38.0",
68 | "markdownlint-cli": "0.45.0",
69 | "prettier": "3.5.3",
70 | "prettier-plugin-curly": "0.3.1",
71 | "prettier-plugin-packagejson": "2.5.10",
72 | "prettier-plugin-sh": "0.17.4",
73 | "release-it": "19.0.2",
74 | "sentences-per-line": "0.3.0",
75 | "tsup": "8.5.0",
76 | "typescript": "5.8.2",
77 | "typescript-eslint": "8.32.1",
78 | "vitest": "3.1.3"
79 | },
80 | "peerDependencies": {
81 | "typescript": ">=5.8.0"
82 | },
83 | "packageManager": "pnpm@10.11.0",
84 | "engines": {
85 | "node": ">=24.0.0"
86 | },
87 | "publishConfig": {
88 | "provenance": true
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/createRule.ts:
--------------------------------------------------------------------------------
1 | import { RuleAbout, RuleDefinition } from "./types/rules.js";
2 | import { AnyOptionalSchema } from "./types/shapes.js";
3 |
4 | export function createRule<
5 | const About extends RuleAbout,
6 | const Message extends string,
7 | >(definition: RuleDefinition): typeof definition;
8 | export function createRule<
9 | const About extends RuleAbout,
10 | const Message extends string,
11 | const OptionsSchema extends AnyOptionalSchema,
12 | >(definition: RuleDefinition): typeof definition;
13 | export function createRule<
14 | const About extends RuleAbout,
15 | const Message extends string,
16 | const OptionsSchema extends AnyOptionalSchema | undefined,
17 | >(definition: RuleDefinition) {
18 | return definition;
19 | }
20 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { createRule } from "./createRule.js";
2 | export { ts } from "./plugin.js";
3 | export { createPlugin } from "./plugins/createPlugin.js";
4 | export { RuleTester } from "./testing/RuleTester.js";
5 |
--------------------------------------------------------------------------------
/src/plugin.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from "./plugins/createPlugin.js";
2 | import consecutiveNonNullAssertions from "./rules/consecutiveNonNullAssertions.js";
3 | import forInArrays from "./rules/forInArrays.js";
4 | import namespaceDeclarations from "./rules/namespaceDeclarations.js";
5 |
6 | export const ts = createPlugin({
7 | name: "ts",
8 | rules: [forInArrays, consecutiveNonNullAssertions, namespaceDeclarations],
9 | });
10 |
--------------------------------------------------------------------------------
/src/plugins/createPlugin.test-d.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, vi } from "vitest";
2 | import z from "zod";
3 |
4 | import { createRule } from "../createRule.js";
5 | import { createPlugin } from "./createPlugin.js";
6 |
7 | const ruleStandalone = createRule({
8 | about: {
9 | id: "standalone",
10 | },
11 | messages: { "": "" },
12 | setup: vi.fn(),
13 | });
14 |
15 | const ruleWithOptionalOption = createRule({
16 | about: {
17 | id: "withOptionalOption",
18 | },
19 | messages: { "": "" },
20 | options: {
21 | value: z.string().optional(),
22 | },
23 | setup: vi.fn(),
24 | });
25 |
26 | describe(createPlugin, () => {
27 | const plugin = createPlugin({
28 | name: "test",
29 | rules: [ruleStandalone, ruleWithOptionalOption],
30 | });
31 |
32 | describe("rules", () => {
33 | it("produces a type error when a rule option is the wrong type", () => {
34 | plugin.rules({
35 | withOptionalOption: {
36 | // @ts-expect-error -- This should report that a string is required
37 | value: 123,
38 | },
39 | });
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/plugins/createPlugin.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi } from "vitest";
2 | import z from "zod";
3 |
4 | import { createRule } from "../createRule.js";
5 | import { createPlugin } from "./createPlugin.js";
6 |
7 | const ruleStandalone = createRule({
8 | about: {
9 | id: "standalone",
10 | preset: "first",
11 | },
12 | messages: { "": "" },
13 | setup: vi.fn(),
14 | });
15 |
16 | const ruleWithOptionalOption = createRule({
17 | about: {
18 | id: "withOptionalOption",
19 | preset: "second",
20 | },
21 | messages: { "": "" },
22 | options: {
23 | value: z.string().optional(),
24 | },
25 | setup: vi.fn(),
26 | });
27 |
28 | describe(createPlugin, () => {
29 | const plugin = createPlugin({
30 | name: "test",
31 | rules: [ruleStandalone, ruleWithOptionalOption],
32 | });
33 |
34 | describe("presets", () => {
35 | it("groups rules by about.preset when it exists", () => {
36 | expect(plugin.presets).toEqual({
37 | first: [ruleStandalone],
38 | second: [ruleWithOptionalOption],
39 | });
40 | });
41 | });
42 |
43 | describe("rules", () => {
44 | it("returns a rule with options when specified with an option", () => {
45 | const value = "abc";
46 | const rules = plugin.rules({
47 | withOptionalOption: { value },
48 | });
49 |
50 | expect(rules).toEqual([
51 | {
52 | options: { value },
53 | rule: ruleWithOptionalOption,
54 | },
55 | ]);
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/plugins/createPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Plugin, PluginPresets } from "../types/plugins.js";
2 | import { RuleAbout, RuleDefinition } from "../types/rules.js";
3 | import { AnyOptionalSchema } from "../types/shapes.js";
4 |
5 | export interface CreatePluginOptions<
6 | About extends RuleAbout,
7 | Rules extends RuleDefinition[],
8 | > {
9 | name: string;
10 | rules: Rules;
11 | }
12 |
13 | export function createPlugin<
14 | const About extends RuleAbout,
15 | // TODO: How to properly constrain this type parameter?
16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
17 | const Rules extends RuleDefinition[],
18 | >({ name, rules }: CreatePluginOptions): Plugin {
19 | const presets = Object.groupBy(
20 | rules.filter((rule) => typeof rule.about.preset === "string"),
21 | // TODO: Figure out inferred type predicate...
22 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
23 | (rule) => rule.about.preset!,
24 | ) as PluginPresets;
25 |
26 | const rulesById = new Map(rules.map((rule) => [rule.about.id, rule]));
27 |
28 | return {
29 | name,
30 | presets,
31 | // @ts-expect-error -- TODO: Figure out what to assert...?
32 | rules: (configuration) => {
33 | return Object.entries(configuration).map(([id, options]) => ({
34 | options,
35 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
36 | rule: rulesById.get(id)!,
37 | }));
38 | },
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/src/rules/consecutiveNonNullAssertions.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from "vitest";
2 |
3 | import { RuleTester } from "../testing/RuleTester.js";
4 | import rule from "./consecutiveNonNullAssertions.js";
5 |
6 | const ruleTester = new RuleTester({
7 | describe,
8 | it,
9 | });
10 |
11 | ruleTester.describe(rule, {
12 | invalid: [
13 | {
14 | code: `
15 | declare const outer: { inner: number } | null;
16 | outer!!.inner;
17 | `,
18 | output: `
19 | declare const outer: { inner: number } | null;
20 | outer!.inner;
21 | `,
22 | snapshot: `
23 | declare const outer: { inner: number } | null;
24 | outer!!.inner;
25 | ~~
26 | Unnecessary consecutive non-null assertion operator.
27 | `,
28 | },
29 | ],
30 | valid: [
31 | `
32 | declare const outer: { inner: number } | null;
33 | outer!.inner;
34 | `,
35 | `
36 | declare const outer: { inner: number } | null;
37 | outer?.inner!;
38 | `,
39 | ],
40 | });
41 |
--------------------------------------------------------------------------------
/src/rules/consecutiveNonNullAssertions.ts:
--------------------------------------------------------------------------------
1 | import * as ts from "typescript";
2 |
3 | import { createRule } from "../createRule.js";
4 |
5 | export default createRule({
6 | about: {
7 | id: "consecutiveNonNullAssertions",
8 | preset: "logical",
9 | },
10 | messages: {
11 | consecutiveNonNullAssertion:
12 | "Unnecessary consecutive non-null assertion operator.",
13 | },
14 | setup(context) {
15 | return {
16 | NonNullExpression(node) {
17 | if (node.parent.kind === ts.SyntaxKind.NonNullExpression) {
18 | context.report({
19 | message: "consecutiveNonNullAssertion",
20 | range: {
21 | begin: node.end,
22 | end: node.parent.end + 1,
23 | },
24 | });
25 | }
26 | },
27 | };
28 | },
29 | });
30 |
--------------------------------------------------------------------------------
/src/rules/forInArrays.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from "vitest";
2 |
3 | import { RuleTester } from "../testing/RuleTester.js";
4 | import rule from "./forInArrays.js";
5 |
6 | const ruleTester = new RuleTester({
7 | describe,
8 | it,
9 | });
10 |
11 | ruleTester.describe(rule, {
12 | invalid: [
13 | {
14 | code: `
15 | declare const array: string[];
16 | for (const i in array) {}
17 | `,
18 | snapshot: `
19 | declare const array: string[];
20 | for (const i in array) {}
21 | ~~~~~~~~~~~~~~~~~~~~~~
22 | Avoid using for-in loops over arrays, as they have surprising behavior that often leads to bugs.
23 | `,
24 | },
25 | ],
26 | valid: [
27 | `
28 | declare const array: string[];
29 | for (const i of array) {}
30 | `,
31 | ],
32 | });
33 |
--------------------------------------------------------------------------------
/src/rules/forInArrays.ts:
--------------------------------------------------------------------------------
1 | import * as tsutils from "ts-api-utils";
2 | import * as ts from "typescript";
3 |
4 | import { createRule } from "../createRule.js";
5 | import { getConstrainedTypeAtLocation } from "./utils/getConstrainedType.js";
6 | import { isTypeRecursive } from "./utils/isTypeRecursive.js";
7 |
8 | export default createRule({
9 | about: {
10 | id: "forInArrays",
11 | preset: "logical",
12 | },
13 | messages: {
14 | preferModules:
15 | "Avoid using for-in loops over arrays, as they have surprising behavior that often leads to bugs.",
16 | },
17 | setup(context) {
18 | function hasNumberLikeLength(type: ts.Type): boolean {
19 | const lengthProperty = type.getProperty("length");
20 |
21 | if (lengthProperty == null) {
22 | return false;
23 | }
24 |
25 | return tsutils.isTypeFlagSet(
26 | context.typeChecker.getTypeOfSymbol(lengthProperty),
27 | ts.TypeFlags.NumberLike,
28 | );
29 | }
30 |
31 | function isArrayLike(type: ts.Type): boolean {
32 | return isTypeRecursive(
33 | type,
34 | (t) => t.getNumberIndexType() != null && hasNumberLikeLength(t),
35 | );
36 | }
37 |
38 | return {
39 | ForInStatement(node) {
40 | const type = getConstrainedTypeAtLocation(
41 | node.expression,
42 | context.typeChecker,
43 | );
44 |
45 | if (isArrayLike(type)) {
46 | context.report({
47 | message: "preferModules",
48 | range: {
49 | begin: node.getStart(),
50 | end: node.statement.getStart() - 1,
51 | },
52 | });
53 | }
54 | },
55 | };
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/src/rules/namespaceDeclarations.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from "vitest";
2 |
3 | import { RuleTester } from "../testing/RuleTester.js";
4 | import rule from "./namespaceDeclarations.js";
5 |
6 | const ruleTester = new RuleTester({
7 | describe,
8 | it,
9 | });
10 |
11 | ruleTester.describe(rule, {
12 | invalid: [
13 | {
14 | code: `
15 | namespace name {}
16 | `,
17 | snapshot: `
18 | namespace name {}
19 | ~~~~~~~~~
20 | Prefer using ECMAScript modules over legacy TypeScript namespaces.
21 | `,
22 | },
23 | ],
24 | valid: [
25 | `declare global {}`,
26 | `declare module 'name' {}`,
27 | {
28 | code: `declare module name {}`,
29 | options: { allowDeclarations: true },
30 | },
31 | {
32 | code: `declare namespace name {}`,
33 | options: { allowDeclarations: true },
34 | },
35 | {
36 | code: `
37 | declare namespace outer {
38 | namespace inner {}
39 | }`,
40 | options: { allowDeclarations: true },
41 | },
42 | {
43 | code: `namespace name {}`,
44 | fileName: "file.d.ts",
45 | options: { allowDefinitionFiles: true },
46 | },
47 | ],
48 | });
49 |
--------------------------------------------------------------------------------
/src/rules/namespaceDeclarations.ts:
--------------------------------------------------------------------------------
1 | import * as tsutils from "ts-api-utils";
2 | import * as ts from "typescript";
3 | import { z } from "zod";
4 |
5 | import { createRule } from "../createRule.js";
6 |
7 | export default createRule({
8 | about: {
9 | id: "namespaceDeclarations",
10 | preset: "logical",
11 | },
12 | messages: {
13 | preferModules:
14 | "Prefer using ECMAScript modules over legacy TypeScript namespaces.",
15 | },
16 | options: {
17 | allowDeclarations: z.boolean().default(false),
18 | allowDefinitionFiles: z.boolean().default(false),
19 | },
20 | setup(context, { allowDeclarations, allowDefinitionFiles }) {
21 | if (allowDefinitionFiles && context.sourceFile.isDeclarationFile) {
22 | return;
23 | }
24 |
25 | return {
26 | ModuleDeclaration(node) {
27 | if (
28 | node.parent.kind !== ts.SyntaxKind.SourceFile ||
29 | node.name.kind !== ts.SyntaxKind.Identifier ||
30 | node.name.text === "global"
31 | ) {
32 | return;
33 | }
34 |
35 | if (
36 | allowDeclarations &&
37 | tsutils.includesModifier(node.modifiers, ts.SyntaxKind.DeclareKeyword)
38 | ) {
39 | return;
40 | }
41 |
42 | context.report({
43 | message: "preferModules",
44 | range: node.getChildAt(0),
45 | });
46 | },
47 | };
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/src/rules/utils/getConstrainedType.ts:
--------------------------------------------------------------------------------
1 | import * as ts from "typescript";
2 |
3 | export function getConstrainedTypeAtLocation(
4 | node: ts.Node,
5 | typeChecker: ts.TypeChecker,
6 | ) {
7 | const type = typeChecker.getTypeAtLocation(node);
8 | return typeChecker.getBaseConstraintOfType(type) ?? type;
9 | }
10 |
--------------------------------------------------------------------------------
/src/rules/utils/isTypeRecursive.ts:
--------------------------------------------------------------------------------
1 | import * as ts from "typescript";
2 |
3 | export function isTypeRecursive(
4 | type: ts.Type,
5 | predicate: (t: ts.Type) => boolean,
6 | ): boolean {
7 | return type.isUnionOrIntersection()
8 | ? type.types.some((subType) => isTypeRecursive(subType, predicate))
9 | : predicate(type);
10 | }
11 |
--------------------------------------------------------------------------------
/src/testing/RuleTester.ts:
--------------------------------------------------------------------------------
1 | import assert from "node:assert";
2 |
3 | import { AnyRuleDefinition } from "../types/rules.js";
4 | import { AnyOptionalSchema, InferredObject } from "../types/shapes.js";
5 | import { InvalidTestCase, ValidTestCase } from "../types/testing.js";
6 | import { createReportSnapshot } from "./createReportSnapshot.js";
7 | import { runTestCaseRule } from "./runTestCaseRule.js";
8 |
9 | export interface RuleTesterOptions {
10 | describe?: TesterSetup;
11 | it?: TesterSetup;
12 | scope?: Record;
13 | }
14 |
15 | export interface TestCases {
16 | invalid: InvalidTestCase[];
17 | valid: ValidTestCase[];
18 | }
19 |
20 | export type TesterSetup = (description: string, setup: () => void) => void;
21 |
22 | export class RuleTester {
23 | #describe: TesterSetup;
24 | #it: TesterSetup;
25 |
26 | constructor({ describe, it, scope = globalThis }: RuleTesterOptions = {}) {
27 | this.#describe = defaultTo(describe, scope, "describe");
28 | this.#it = defaultTo(it, scope, "it");
29 | }
30 |
31 | describe(
32 | rule: AnyRuleDefinition,
33 | { invalid, valid }: TestCases>,
34 | ) {
35 | this.#describe(rule.about.id, () => {
36 | this.#describe("invalid", () => {
37 | for (const testCase of invalid) {
38 | this.#itInvalidCase(rule, testCase);
39 | }
40 | });
41 |
42 | this.#describe("valid", () => {
43 | for (const testCase of valid) {
44 | this.#itValidCase(rule, testCase);
45 | }
46 | });
47 | });
48 | }
49 |
50 | #itInvalidCase(
51 | rule: AnyRuleDefinition,
52 | testCase: InvalidTestCase>,
53 | ) {
54 | this.#it(testCase.code, () => {
55 | const reports = runTestCaseRule(
56 | {
57 | // TODO: Figure out a way around the type assertion...
58 | options: (testCase.options ?? {}) as InferredObject,
59 | rule,
60 | },
61 | testCase,
62 | );
63 | const actual = createReportSnapshot(testCase.code, reports);
64 |
65 | assert.equal(actual, testCase.snapshot);
66 | });
67 | }
68 |
69 | #itValidCase(
70 | rule: AnyRuleDefinition,
71 | testCaseRaw: ValidTestCase>,
72 | ) {
73 | const testCase =
74 | typeof testCaseRaw === "string" ? { code: testCaseRaw } : testCaseRaw;
75 |
76 | this.#it(testCase.code, () => {
77 | const reports = runTestCaseRule(
78 | {
79 | // TODO: Figure out a way around the type assertion...
80 | options: (testCase.options ?? {}) as InferredObject,
81 | rule,
82 | },
83 | testCase,
84 | );
85 |
86 | if (reports.length) {
87 | assert.deepStrictEqual(
88 | createReportSnapshot(testCase.code, reports),
89 | testCase.code,
90 | );
91 | }
92 | });
93 | }
94 | }
95 |
96 | function defaultTo(
97 | provided: TesterSetup | undefined,
98 | scope: Record,
99 | scopeKey: string,
100 | ): TesterSetup {
101 | if (provided) {
102 | return provided;
103 | }
104 |
105 | if (scopeKey in scope && typeof scope[scopeKey] === "function") {
106 | return scope[scopeKey] as TesterSetup;
107 | }
108 |
109 | throw new Error(`No ${scopeKey} function found`);
110 | }
111 |
--------------------------------------------------------------------------------
/src/testing/createProgramSourceFile.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @eslint-community/eslint-comments/disable-enable-pair
2 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
3 |
4 | import {
5 | createFSBackedSystem,
6 | createVirtualTypeScriptEnvironment,
7 | } from "@typescript/vfs";
8 | import { CachedFactory } from "cached-factory";
9 | import path from "node:path";
10 | import ts from "typescript";
11 |
12 | const projectRoot = path.join(import.meta.dirname, "../..");
13 |
14 | const environments = new CachedFactory((fileName: string) => {
15 | const system = createFSBackedSystem(
16 | new Map([[fileName, "// ..."]]),
17 | projectRoot,
18 | ts,
19 | );
20 |
21 | return createVirtualTypeScriptEnvironment(system, [fileName], ts, {
22 | skipLibCheck: true,
23 | target: ts.ScriptTarget.ESNext,
24 | });
25 | });
26 |
27 | export function createProgramSourceFile(fileName: string, sourceText: string) {
28 | const environment = environments.get(fileName);
29 | environment.updateFile(fileName, sourceText);
30 |
31 | const sourceFile = environment.getSourceFile(fileName)!;
32 | const typeChecker = environment.languageService
33 | .getProgram()!
34 | .getTypeChecker();
35 |
36 | return { sourceFile, typeChecker };
37 | }
38 |
--------------------------------------------------------------------------------
/src/testing/createReportSnapshot.ts:
--------------------------------------------------------------------------------
1 | import { NormalizedRuleReport } from "./runTestCaseRule.js";
2 |
3 | export function createReportSnapshot(
4 | sourceText: string,
5 | reports: NormalizedRuleReport[],
6 | ) {
7 | let result = sourceText;
8 |
9 | for (const report of reports.toReversed()) {
10 | result = createReportSnapshotAt(result, report);
11 | }
12 |
13 | return result;
14 | }
15 |
16 | function createReportSnapshotAt(
17 | sourceText: string,
18 | report: NormalizedRuleReport,
19 | ) {
20 | const range = report.range;
21 |
22 | const lineEndIndex = ifNegative(
23 | sourceText.indexOf("\n", range.begin),
24 | sourceText.length,
25 | );
26 | const lineStartIndex = ifNegative(
27 | sourceText.lastIndexOf("\n", range.begin),
28 | 0,
29 | );
30 |
31 | const column = ifNegative(range.begin - lineStartIndex - 2, 0);
32 | const width = range.end - range.begin;
33 |
34 | const injectionPrefix = " ".repeat(column);
35 | const injectedLines = [
36 | injectionPrefix + "~".repeat(width),
37 | injectionPrefix + report.message,
38 | ];
39 |
40 | return [
41 | sourceText.slice(0, lineEndIndex),
42 | ...injectedLines,
43 | sourceText.slice(lineEndIndex + 1),
44 | ].join("\n");
45 | }
46 |
47 | function ifNegative(value: number, fallback: number) {
48 | return value < 0 ? fallback : value;
49 | }
50 |
--------------------------------------------------------------------------------
/src/testing/normalizeRange.ts:
--------------------------------------------------------------------------------
1 | import type * as ts from "typescript";
2 |
3 | import { ReportRange } from "../types/reports.js";
4 |
5 | export function normalizeRange(range: ReportRange) {
6 | return isNode(range)
7 | ? { begin: range.getStart(), end: range.getEnd() }
8 | : range;
9 | }
10 |
11 | function isNode(value: unknown): value is ts.Node {
12 | return typeof value === "object" && value !== null && "kind" in value;
13 | }
14 |
--------------------------------------------------------------------------------
/src/testing/runTestCaseRule.ts:
--------------------------------------------------------------------------------
1 | import * as ts from "typescript";
2 |
3 | import { RuleConfiguration } from "../types/configurations.js";
4 | import { ReportRangeObject, RuleReport } from "../types/reports.js";
5 | import { AnyOptionalSchema } from "../types/shapes.js";
6 | import { createProgramSourceFile } from "./createProgramSourceFile.js";
7 | import { normalizeRange } from "./normalizeRange.js";
8 |
9 | export interface NormalizedRuleReport {
10 | message: Message;
11 | range: ReportRangeObject;
12 | }
13 |
14 | export interface NormalizedTestCase {
15 | code: string;
16 | fileName?: string;
17 | }
18 |
19 | export function runTestCaseRule<
20 | OptionsSchema extends AnyOptionalSchema | undefined,
21 | >(
22 | { options, rule }: Required>,
23 | { code, fileName = "file.ts" }: NormalizedTestCase,
24 | ) {
25 | const { sourceFile, typeChecker } = createProgramSourceFile(fileName, code);
26 |
27 | const reports: NormalizedRuleReport[] = [];
28 |
29 | const context = {
30 | report(report: RuleReport) {
31 | reports.push({
32 | message: rule.messages[report.message],
33 | range: normalizeRange(report.range),
34 | });
35 | },
36 | sourceFile,
37 | typeChecker,
38 | };
39 |
40 | const visitors = rule.setup(context, options);
41 |
42 | if (!visitors) {
43 | return reports;
44 | }
45 |
46 | function visit(node: ts.Node) {
47 | // @ts-expect-error - TODO: Figure this out later...
48 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call
49 | visitors[ts.SyntaxKind[node.kind]]?.(node);
50 |
51 | node.forEachChild(visit);
52 | }
53 |
54 | sourceFile.forEachChild(visit);
55 |
56 | return reports;
57 | }
58 |
--------------------------------------------------------------------------------
/src/types/configurations.ts:
--------------------------------------------------------------------------------
1 | import { AnyRuleDefinition } from "./rules.js";
2 | import { AnyOptionalSchema, InferredObject } from "./shapes.js";
3 |
4 | export interface RuleConfiguration<
5 | OptionsSchema extends AnyOptionalSchema | undefined,
6 | > {
7 | options?: InferredObject;
8 | rule: AnyRuleDefinition;
9 | }
10 |
--------------------------------------------------------------------------------
/src/types/context.ts:
--------------------------------------------------------------------------------
1 | import type * as ts from "typescript";
2 |
3 | import { RuleReport } from "./reports.js";
4 |
5 | export interface RuleContext {
6 | report: RuleReporter;
7 | sourceFile: ts.SourceFile;
8 | typeChecker: ts.TypeChecker;
9 | }
10 |
11 | export type RuleReporter = (
12 | report: RuleReport,
13 | ) => void;
14 |
--------------------------------------------------------------------------------
/src/types/nodes.ts:
--------------------------------------------------------------------------------
1 | import type * as ts from "typescript";
2 |
3 | // TODO: Surely there's a better way to do this...
4 | // ...but I haven't checked how to do it without slow type operations.
5 |
6 | export type TSNode = TSNodesByName[keyof TSNodesByName];
7 |
8 | export type TSNodeName = keyof TSNodesByName;
9 |
10 | export interface TSNodesByName {
11 | ArrayBindingPattern: ts.ArrayBindingPattern;
12 | ArrayLiteralExpression: ts.ArrayLiteralExpression;
13 | ArrowFunction: ts.ArrowFunction;
14 | AsExpression: ts.AsExpression;
15 | AwaitExpression: ts.AwaitExpression;
16 | BigIntLiteral: ts.BigIntLiteral;
17 | BinaryExpression: ts.BinaryExpression;
18 | BindingElement: ts.BindingElement;
19 | Block: ts.Block;
20 | BreakStatement: ts.BreakStatement;
21 | Bundle: ts.Bundle;
22 | CallExpression: ts.CallExpression;
23 | CaseBlock: ts.CaseBlock;
24 | CaseClause: ts.CaseClause;
25 | CatchClause: ts.CatchClause;
26 | ClassDeclaration: ts.ClassDeclaration;
27 | ClassExpression: ts.ClassExpression;
28 | ClassStaticBlockDeclaration: ts.ClassStaticBlockDeclaration;
29 | CommaListExpression: ts.CommaListExpression;
30 | ComputedPropertyName: ts.ComputedPropertyName;
31 | ConditionalExpression: ts.ConditionalExpression;
32 | ConditionalType: ts.ConditionalType;
33 | ContinueStatement: ts.ContinueStatement;
34 | DebuggerStatement: ts.DebuggerStatement;
35 | Decorator: ts.Decorator;
36 | DefaultClause: ts.DefaultClause;
37 | DeleteExpression: ts.DeleteExpression;
38 | DoStatement: ts.DoStatement;
39 | ElementAccessExpression: ts.ElementAccessExpression;
40 | EmptyStatement: ts.EmptyStatement;
41 | EnumDeclaration: ts.EnumDeclaration;
42 | EnumMember: ts.EnumMember;
43 | ExportAssignment: ts.ExportAssignment;
44 | ExportDeclaration: ts.ExportDeclaration;
45 | ExportSpecifier: ts.ExportSpecifier;
46 | ExpressionStatement: ts.ExpressionStatement;
47 | ExpressionWithTypeArguments: ts.ExpressionWithTypeArguments;
48 | ExternalModuleReference: ts.ExternalModuleReference;
49 | ForInStatement: ts.ForInStatement;
50 | ForOfStatement: ts.ForOfStatement;
51 | ForStatement: ts.ForStatement;
52 | FunctionDeclaration: ts.FunctionDeclaration;
53 | FunctionExpression: ts.FunctionExpression;
54 | HeritageClause: ts.HeritageClause;
55 | Identifier: ts.Identifier;
56 | IfStatement: ts.IfStatement;
57 | ImportAttribute: ts.ImportAttribute;
58 | ImportAttributes: ts.ImportAttributes;
59 | ImportClause: ts.ImportClause;
60 | ImportDeclaration: ts.ImportDeclaration;
61 | ImportEqualsDeclaration: ts.ImportEqualsDeclaration;
62 | ImportSpecifier: ts.ImportSpecifier;
63 | IndexedAccessType: ts.IndexedAccessType;
64 | InterfaceDeclaration: ts.InterfaceDeclaration;
65 | IntersectionType: ts.IntersectionType;
66 | JsxAttribute: ts.JsxAttribute;
67 | JsxAttributes: ts.JsxAttributes;
68 | JsxClosingElement: ts.JsxClosingElement;
69 | JsxClosingFragment: ts.JsxClosingFragment;
70 | JsxElement: ts.JsxElement;
71 | JsxExpression: ts.JsxExpression;
72 | JsxFragment: ts.JsxFragment;
73 | JsxNamespacedName: ts.JsxNamespacedName;
74 | JsxOpeningElement: ts.JsxOpeningElement;
75 | JsxOpeningFragment: ts.JsxOpeningFragment;
76 | JsxSelfClosingElement: ts.JsxSelfClosingElement;
77 | JsxSpreadAttribute: ts.JsxSpreadAttribute;
78 | JsxText: ts.JsxText;
79 | LabeledStatement: ts.LabeledStatement;
80 | LiteralType: ts.LiteralType;
81 | MappedTypeNode: ts.MappedTypeNode;
82 | MetaProperty: ts.MetaProperty;
83 | MethodDeclaration: ts.MethodDeclaration;
84 | MethodSignature: ts.MethodSignature;
85 | MissingDeclaration: ts.MissingDeclaration;
86 | ModuleBlock: ts.ModuleBlock;
87 | ModuleDeclaration: ts.ModuleDeclaration;
88 | NamedExports: ts.NamedExports;
89 | NamedImports: ts.NamedImports;
90 | NamedTupleMember: ts.NamedTupleMember;
91 | NamespaceExport: ts.NamespaceExport;
92 | NamespaceExportDeclaration: ts.NamespaceExportDeclaration;
93 | NamespaceImport: ts.NamespaceImport;
94 | NewExpression: ts.NewExpression;
95 | NonNullExpression: ts.NonNullExpression;
96 | NoSubstitutionTemplateLiteral: ts.NoSubstitutionTemplateLiteral;
97 | NotEmittedStatement: ts.NotEmittedStatement;
98 | NotEmittedTypeElement: ts.NotEmittedTypeElement;
99 | NumericLiteral: ts.NumericLiteral;
100 | ObjectBindingPattern: ts.ObjectBindingPattern;
101 | ObjectLiteralExpression: ts.ObjectLiteralExpression;
102 | OmittedExpression: ts.OmittedExpression;
103 | ParenthesizedExpression: ts.ParenthesizedExpression;
104 | PartiallyEmittedExpression: ts.PartiallyEmittedExpression;
105 | PostfixUnaryExpression: ts.PostfixUnaryExpression;
106 | PrefixUnaryExpression: ts.PrefixUnaryExpression;
107 | PrivateIdentifier: ts.PrivateIdentifier;
108 | PropertyAccessExpression: ts.PropertyAccessExpression;
109 | PropertyAssignment: ts.PropertyAssignment;
110 | PropertyDeclaration: ts.PropertyDeclaration;
111 | PropertySignature: ts.PropertySignature;
112 | QualifiedName: ts.QualifiedName;
113 | RegularExpressionLiteral: ts.RegularExpressionLiteral;
114 | ReturnStatement: ts.ReturnStatement;
115 | SatisfiesExpression: ts.SatisfiesExpression;
116 | SemicolonClassElement: ts.SemicolonClassElement;
117 | ShorthandPropertyAssignment: ts.ShorthandPropertyAssignment;
118 | SourceFile: ts.SourceFile;
119 | SpreadAssignment: ts.SpreadAssignment;
120 | SpreadElement: ts.SpreadElement;
121 | StringLiteral: ts.StringLiteral;
122 | SwitchStatement: ts.SwitchStatement;
123 | SyntaxList: ts.SyntaxList;
124 | SyntheticExpression: ts.SyntheticExpression;
125 | TaggedTemplateExpression: ts.TaggedTemplateExpression;
126 | TemplateExpression: ts.TemplateExpression;
127 | TemplateHead: ts.TemplateHead;
128 | TemplateLiteralType: ts.TemplateLiteralType;
129 | TemplateLiteralTypeSpan: ts.TemplateLiteralTypeSpan;
130 | TemplateMiddle: ts.TemplateMiddle;
131 | TemplateSpan: ts.TemplateSpan;
132 | TemplateTail: ts.TemplateTail;
133 | ThrowStatement: ts.ThrowStatement;
134 | TryStatement: ts.TryStatement;
135 | TupleType: ts.TupleType;
136 | TypeAliasDeclaration: ts.TypeAliasDeclaration;
137 | TypeOfExpression: ts.TypeOfExpression;
138 | TypeParameter: ts.TypeParameter;
139 | TypePredicate: ts.TypePredicate;
140 | TypeReference: ts.TypeReference;
141 | UnionType: ts.UnionType;
142 | VariableDeclaration: ts.VariableDeclaration;
143 | VariableDeclarationList: ts.VariableDeclarationList;
144 | VariableStatement: ts.VariableStatement;
145 | VoidExpression: ts.VoidExpression;
146 | WhileStatement: ts.WhileStatement;
147 | WithStatement: ts.WithStatement;
148 | YieldExpression: ts.YieldExpression;
149 | }
150 |
--------------------------------------------------------------------------------
/src/types/plugins.ts:
--------------------------------------------------------------------------------
1 | import { RuleAbout, RuleDefinition } from "./rules.js";
2 | import { AnyOptionalSchema, InferredObject } from "./shapes.js";
3 |
4 | export interface Plugin<
5 | About extends RuleAbout,
6 | Rules extends RuleDefinition[],
7 | > {
8 | name: string;
9 | presets: PluginPresets;
10 | rules: PluginRulesFactory;
11 | }
12 |
13 | export type PluginPresets = Record<
14 | About["preset"] extends string ? About["preset"] : never,
15 | RuleDefinition[]
16 | >;
17 |
18 | export type PluginRulesFactory<
19 | Rules extends RuleDefinition<
20 | RuleAbout,
21 | string,
22 | AnyOptionalSchema | undefined
23 | >[],
24 | > = (rulesOptions: PluginRulesOptions) => Rules;
25 |
26 | export type PluginRulesOptions<
27 | Rules extends RuleDefinition<
28 | RuleAbout,
29 | string,
30 | AnyOptionalSchema | undefined
31 | >[],
32 | > = {
33 | [Rule in Rules[number] as Rule["about"]["id"]]?: Rule["options"] extends undefined
34 | ? boolean
35 | : boolean | InferredObject;
36 | };
37 |
--------------------------------------------------------------------------------
/src/types/reports.ts:
--------------------------------------------------------------------------------
1 | import type * as ts from "typescript";
2 |
3 | export type ReportRange = ReportRangeObject | ts.Node;
4 |
5 | export interface ReportRangeObject {
6 | begin: number;
7 | end: number;
8 | }
9 |
10 | export interface RuleReport {
11 | message: Message;
12 | range: ReportRange;
13 | }
14 |
--------------------------------------------------------------------------------
/src/types/rules.ts:
--------------------------------------------------------------------------------
1 | import { RuleContext } from "./context.js";
2 | import { TSNode, TSNodeName, TSNodesByName } from "./nodes.js";
3 | import { AnyOptionalSchema, InferredObject } from "./shapes.js";
4 |
5 | export type AnyRuleDefinition<
6 | OptionsSchema extends AnyOptionalSchema | undefined =
7 | | AnyOptionalSchema
8 | | undefined,
9 | > = RuleDefinition;
10 |
11 | export interface RuleAbout {
12 | id: string;
13 | preset?: string;
14 | }
15 |
16 | export interface RuleDefinition<
17 | About extends RuleAbout,
18 | Message extends string,
19 | OptionsSchema extends AnyOptionalSchema | undefined,
20 | > {
21 | about: About;
22 | messages: Record;
23 | options?: OptionsSchema;
24 | setup: RuleSetup>;
25 | }
26 |
27 | export type RuleSetup = (
28 | context: RuleContext,
29 | options: Options,
30 | ) => RuleVisitors | undefined;
31 |
32 | export type RuleVisitor = (node: Node) => void;
33 |
34 | export type RuleVisitors = {
35 | [Kind in TSNodeName]?: RuleVisitor;
36 | };
37 |
--------------------------------------------------------------------------------
/src/types/shapes.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | /**
4 | * Any object containing Zod schemas that are optional.
5 | * In other words, allows providing an empty object {} value.
6 | */
7 | export type AnyOptionalSchema = Record<
8 | string,
9 | z.ZodDefault | z.ZodOptional
10 | >;
11 |
12 | /**
13 | * Given an object containing Zod schemas, produces the equivalent runtime type.
14 | * @example
15 | * ```ts
16 | * InferredObject<{ value: z.ZodNumber }>
17 | * ```
18 | * is the same as:
19 | * ```ts
20 | * { value: number }
21 | * ```
22 | */
23 | export type InferredObject<
24 | OptionsSchema extends AnyOptionalSchema | undefined,
25 | > = OptionsSchema extends AnyOptionalSchema
26 | ? Partial>>
27 | : undefined;
28 |
--------------------------------------------------------------------------------
/src/types/testing.ts:
--------------------------------------------------------------------------------
1 | export interface CommonTestCase {
2 | code: string;
3 | fileName?: string;
4 | options?: Options;
5 | }
6 |
7 | export interface InvalidTestCase
8 | extends CommonTestCase {
9 | output?: string;
10 | snapshot: string;
11 | }
12 |
13 | export type ValidTestCase =
14 | | string
15 | | ValidTestCaseObject;
16 |
17 | export type ValidTestCaseObject =
18 | CommonTestCase;
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "declarationMap": true,
5 | "esModuleInterop": true,
6 | "lib": ["ES2024"],
7 | "module": "NodeNext",
8 | "moduleResolution": "NodeNext",
9 | "noEmit": true,
10 | "resolveJsonModule": true,
11 | "skipLibCheck": true,
12 | "strict": true,
13 | "target": "ES2024"
14 | },
15 | "include": ["src"]
16 | }
17 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | bundle: false,
5 | clean: true,
6 | dts: true,
7 | entry: ["src/**/*.ts", "!src/**/*.test.*"],
8 | format: "esm",
9 | outDir: "lib",
10 | });
11 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defaultExclude, defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | clearMocks: true,
6 | coverage: {
7 | all: true,
8 | exclude: [...defaultExclude, "src/index.ts", "**/*.test-d.ts"],
9 | include: ["src"],
10 | reporter: ["html", "lcov"],
11 | },
12 | exclude: ["lib", "node_modules"],
13 | setupFiles: ["console-fail-test/setup"],
14 | testTimeout: 10_000,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------