@alexgorbatchev/typescript-ai-policy 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -37
- package/package.json +1 -1
- package/src/oxlint/createOxlintConfig.ts +2 -1
- package/src/oxlint/plugin.ts +2 -0
- package/src/oxlint/rules/component-file-naming-convention.ts +6 -2
- package/src/oxlint/rules/component-story-file-convention.ts +5 -3
- package/src/oxlint/rules/helpers.ts +10 -0
- package/src/oxlint/rules/hook-test-file-convention.ts +11 -4
- package/src/oxlint/rules/no-i-prefixed-type-aliases.ts +45 -0
- package/src/oxlint/rules/require-template-indent.ts +46 -9
- package/src/oxlint/rules/story-file-location-convention.ts +19 -8
- package/src/oxlint/rules/testid-naming-convention.ts +1 -1
- package/src/semantic-fixes/applyFileChanges.ts +6 -6
- package/src/semantic-fixes/applySemanticFixes.ts +29 -26
- package/src/semantic-fixes/applyTextEdits.ts +20 -20
- package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +49 -49
- package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +41 -41
- package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +15 -89
- package/src/semantic-fixes/providers/createNoIPrefixedTypeAliasesSemanticFixProvider.ts +49 -0
- package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +9 -19
- package/src/semantic-fixes/providers/helpers.ts +117 -0
- package/src/semantic-fixes/readMovedFileTextEdits.ts +7 -7
- package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +2 -2
- package/src/semantic-fixes/runApplySemanticFixes.ts +5 -5
- package/src/semantic-fixes/runOxlintJson.ts +9 -9
- package/src/semantic-fixes/types.ts +35 -41
package/README.md
CHANGED
|
@@ -169,9 +169,10 @@ Repository-local development usage:
|
|
|
169
169
|
|
|
170
170
|
- `bun run fix:semantic -- <target-directory>` — run the same semantic fixer from this repository checkout while developing the package itself.
|
|
171
171
|
|
|
172
|
-
Today the framework applies
|
|
172
|
+
Today the framework applies three conservative semantic fixes:
|
|
173
173
|
|
|
174
174
|
- `@alexgorbatchev/interface-naming-convention` — rename repository-owned interfaces to their required `I*` form when the existing name can be normalized safely.
|
|
175
|
+
- `@alexgorbatchev/no-i-prefixed-type-aliases` — rename repository-owned type aliases to drop the interface-style `I*` prefix when the diagnostic resolves to a concrete type alias name safely.
|
|
175
176
|
- `@alexgorbatchev/test-file-location-convention` — move misplaced `.test.ts` / `.test.tsx` files into a sibling `__tests__/` directory as `__tests__/basename.test.ts[x]` and rewrite the moved file's relative imports.
|
|
176
177
|
|
|
177
178
|
The command and backend shape remain intentionally generic so more rule-backed semantic operations can be added later.
|
|
@@ -191,39 +192,40 @@ The command and backend shape remain intentionally generic so more rule-backed s
|
|
|
191
192
|
|
|
192
193
|
For rule-by-rule rationale plus good/bad examples, see [`src/oxlint/README.md`](./src/oxlint/README.md).
|
|
193
194
|
|
|
194
|
-
| Rule | Policy encoded
|
|
195
|
-
| --------------------------------------------------------------- |
|
|
196
|
-
| `@alexgorbatchev/testid-naming-convention` | Non-story React test ids must be scoped to the owning component as `ComponentName` or `ComponentName--thing`.
|
|
197
|
-
| `@alexgorbatchev/no-react-create-element` | Regular application code must use JSX instead of `createElement`.
|
|
198
|
-
| `@alexgorbatchev/require-component-root-testid` | Non-story exported React components must render a DOM root with `data-testid`/`testId` exactly equal to the component name, and child ids must use `ComponentName--thing`.
|
|
199
|
-
| `@alexgorbatchev/component-file-contract` | Component ownership files may export exactly one main runtime component plus unrestricted type-only API.
|
|
200
|
-
| `@alexgorbatchev/component-file-naming-convention` | Component filenames must match their exported PascalCase component name in either PascalCase or kebab-case form
|
|
201
|
-
| `@alexgorbatchev/component-story-file-convention` | Every component ownership file must have a matching `basename.stories.tsx` file somewhere under a sibling `stories/` directory.
|
|
202
|
-
| `@alexgorbatchev/story-file-location-convention` | Storybook files must live under sibling `stories/` directories and must still match a sibling component basename.
|
|
203
|
-
| `@alexgorbatchev/story-meta-type-annotation` | The default Storybook meta must use a typed `const meta: Meta<typeof ComponentName>` binding instead of object assertions.
|
|
204
|
-
| `@alexgorbatchev/story-export-contract` | Story exports must use typed `Story` bindings, every story must define `play`, and single-story vs multi-story export shapes are enforced.
|
|
205
|
-
| `@alexgorbatchev/hook-file-contract` | Hook ownership files may export exactly one main runtime hook and it must use `export function useThing() {}` form.
|
|
206
|
-
| `@alexgorbatchev/hook-file-naming-convention` | Hook filenames must match their exported hook name as either `useFoo.ts[x]` or `use-foo.ts[x]`.
|
|
207
|
-
| `@alexgorbatchev/hook-test-file-convention` | Every hook ownership file must have a matching `__tests__/basename.test.ts[x]` file somewhere under a sibling `__tests__/` directory, with the same source extension family.
|
|
208
|
-
| `@alexgorbatchev/no-non-running-tests` | Ban skip/todo/gated test modifiers that still leave non-running test code after the Jest rules run.
|
|
209
|
-
| `@alexgorbatchev/no-conditional-logic-in-tests` | Ban `if`, `switch`, and ternary control flow in committed `*.test.ts(x)` files so assertions execute deterministically and test paths stay explicit.
|
|
210
|
-
| `@alexgorbatchev/no-throw-in-tests` | Ban `throw new Error(...)` in committed `*.test.ts(x)` files so failures use explicit `assert(...)` / `assert.fail(...)` calls instead of ad-hoc exception paths.
|
|
211
|
-
| `@alexgorbatchev/no-module-mocking` | Ban whole-module mocking APIs and push tests toward dependency injection plus explicit stubs.
|
|
212
|
-
| `@alexgorbatchev/no-test-file-exports` | Treat `*.test.ts(x)` files as execution units, not shared modules.
|
|
213
|
-
| `@alexgorbatchev/no-imports-from-tests-directory` | Files outside `__tests__/` must not import, require, or re-export modules from any `__tests__/` directory.
|
|
214
|
-
| `@alexgorbatchev/interface-naming-convention` | Repository-owned interfaces must use `I` followed by PascalCase; ambient external contract interfaces such as `Window` stay exempt.
|
|
215
|
-
| `@alexgorbatchev/no-
|
|
216
|
-
| `@alexgorbatchev/
|
|
217
|
-
| `@alexgorbatchev/
|
|
218
|
-
| `@alexgorbatchev/
|
|
219
|
-
| `@alexgorbatchev/no-type-
|
|
220
|
-
| `@alexgorbatchev/no-
|
|
221
|
-
| `@alexgorbatchev/
|
|
222
|
-
| `@alexgorbatchev/
|
|
223
|
-
| `@alexgorbatchev/fixture-
|
|
224
|
-
| `@alexgorbatchev/fixture-export-
|
|
225
|
-
| `@alexgorbatchev/
|
|
226
|
-
| `@alexgorbatchev/no-
|
|
227
|
-
| `@alexgorbatchev/fixture-
|
|
228
|
-
| `@alexgorbatchev/
|
|
229
|
-
| `@alexgorbatchev/
|
|
195
|
+
| Rule | Policy encoded |
|
|
196
|
+
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
197
|
+
| `@alexgorbatchev/testid-naming-convention` | Non-story React test ids must be scoped to the owning component as `ComponentName` or `ComponentName--thing`. |
|
|
198
|
+
| `@alexgorbatchev/no-react-create-element` | Regular application code must use JSX instead of `createElement`. |
|
|
199
|
+
| `@alexgorbatchev/require-component-root-testid` | Non-story exported React components must render a DOM root with `data-testid`/`testId` exactly equal to the component name, and child ids must use `ComponentName--thing`. |
|
|
200
|
+
| `@alexgorbatchev/component-file-contract` | Component ownership files may export exactly one main runtime component plus unrestricted type-only API. |
|
|
201
|
+
| `@alexgorbatchev/component-file-naming-convention` | Component filenames must match their exported PascalCase component name in either PascalCase or kebab-case form; non-component `.tsx` modules should be renamed to `.ts`. |
|
|
202
|
+
| `@alexgorbatchev/component-story-file-convention` | Every component ownership file must have a matching `basename.stories.tsx` file somewhere under a sibling `stories/` directory. |
|
|
203
|
+
| `@alexgorbatchev/story-file-location-convention` | Storybook files must live under sibling `stories/` directories and must still match a sibling component basename. |
|
|
204
|
+
| `@alexgorbatchev/story-meta-type-annotation` | The default Storybook meta must use a typed `const meta: Meta<typeof ComponentName>` binding instead of object assertions. |
|
|
205
|
+
| `@alexgorbatchev/story-export-contract` | Story exports must use typed `Story` bindings, every story must define `play`, and single-story vs multi-story export shapes are enforced. |
|
|
206
|
+
| `@alexgorbatchev/hook-file-contract` | Hook ownership files may export exactly one main runtime hook and it must use `export function useThing() {}` form. |
|
|
207
|
+
| `@alexgorbatchev/hook-file-naming-convention` | Hook filenames must match their exported hook name as either `useFoo.ts[x]` or `use-foo.ts[x]`. |
|
|
208
|
+
| `@alexgorbatchev/hook-test-file-convention` | Every hook ownership file must have a matching `__tests__/basename.test.ts[x]` file somewhere under a sibling `__tests__/` directory, with the same source extension family. |
|
|
209
|
+
| `@alexgorbatchev/no-non-running-tests` | Ban skip/todo/gated test modifiers that still leave non-running test code after the Jest rules run. |
|
|
210
|
+
| `@alexgorbatchev/no-conditional-logic-in-tests` | Ban `if`, `switch`, and ternary control flow in committed `*.test.ts(x)` files so assertions execute deterministically and test paths stay explicit. |
|
|
211
|
+
| `@alexgorbatchev/no-throw-in-tests` | Ban `throw new Error(...)` in committed `*.test.ts(x)` files so failures use explicit `assert(...)` / `assert.fail(...)` calls instead of ad-hoc exception paths. |
|
|
212
|
+
| `@alexgorbatchev/no-module-mocking` | Ban whole-module mocking APIs and push tests toward dependency injection plus explicit stubs. |
|
|
213
|
+
| `@alexgorbatchev/no-test-file-exports` | Treat `*.test.ts(x)` files as execution units, not shared modules. |
|
|
214
|
+
| `@alexgorbatchev/no-imports-from-tests-directory` | Files outside `__tests__/` must not import, require, or re-export modules from any `__tests__/` directory. |
|
|
215
|
+
| `@alexgorbatchev/interface-naming-convention` | Repository-owned interfaces must use `I` followed by PascalCase; ambient external contract interfaces such as `Window` stay exempt. |
|
|
216
|
+
| `@alexgorbatchev/no-i-prefixed-type-aliases` | Type aliases must not use the interface-style `I[A-Z]` prefix; this applies to `type` aliases only and does not change the separate interface naming contract. |
|
|
217
|
+
| `@alexgorbatchev/no-inline-type-expressions` | Explicit type usage must rely on named declarations or inference; do not define object, tuple, function, broad union, intersection, mapped, or conditional types inline at use sites. Narrow `T \| null \| undefined` wrappers stay allowed. |
|
|
218
|
+
| `@alexgorbatchev/require-template-indent` | Multiline template literals that begin on their own line must keep their content indented with the surrounding code so embedded text stays reviewable and intentional; if indentation is significant, normalize the string explicitly with `@alexgorbatchev/dedent-string`. |
|
|
219
|
+
| `@alexgorbatchev/index-file-contract` | `index.ts` must stay a pure barrel: no local definitions, no side effects, only re-exports, and never `index.tsx`. |
|
|
220
|
+
| `@alexgorbatchev/no-type-imports-from-constants` | Types must not be imported from `constants` modules, including inline `import("./constants")` type queries. |
|
|
221
|
+
| `@alexgorbatchev/no-type-exports-from-constants` | `constants.ts` files may export runtime values only; exported types must move to `types.ts`. |
|
|
222
|
+
| `@alexgorbatchev/no-value-exports-from-types` | `types.ts` files may export type-only API only; runtime values and value re-exports must move elsewhere. |
|
|
223
|
+
| `@alexgorbatchev/test-file-location-convention` | Repository-owned `.test.ts` / `.test.tsx` files must live under `__tests__/` directories. `.spec.ts[x]` files are ignored by this rule. The semantic fixer moves misplaced `.test.ts[x]` files into a sibling `__tests__/` directory and rewrites their relative imports. |
|
|
224
|
+
| `@alexgorbatchev/fixture-file-contract` | `__tests__/fixtures.ts(x)` and `stories/fixtures.ts(x)` may export only direct named `const` fixtures and named factory functions. |
|
|
225
|
+
| `@alexgorbatchev/fixture-export-naming-convention` | Fixture entrypoint exports must use `fixture_<lowerCamelCase>` and `factory_<lowerCamelCase>`. |
|
|
226
|
+
| `@alexgorbatchev/fixture-export-type-contract` | Fixture entrypoint exports must declare explicit imported concrete types and must not use `any` or `unknown`. |
|
|
227
|
+
| `@alexgorbatchev/no-fixture-exports-outside-fixture-entrypoint` | `fixture_*` and `factory_*` exports may exist only in nested `fixtures.ts(x)` entrypoints under `__tests__/` or `stories/`. |
|
|
228
|
+
| `@alexgorbatchev/no-inline-fixture-bindings-in-tests` | Test and story files must import `fixture_*` and `factory_*` bindings from a relative `fixtures` module inside the same `__tests__/` or `stories/` tree instead of declaring them inline. |
|
|
229
|
+
| `@alexgorbatchev/fixture-import-path-convention` | Fixture-like imports inside test and story files must be named imports from a relative `fixtures` module inside the same `__tests__/` or `stories/` tree, with no aliasing. |
|
|
230
|
+
| `@alexgorbatchev/no-local-type-declarations-in-fixture-files` | Fixture files and `fixtures/` contents under `__tests__/` or `stories/` must import shared types instead of declaring local types, interfaces, or enums. |
|
|
231
|
+
| `@alexgorbatchev/single-fixture-entrypoint` | Each fixture-support directory under `__tests__/` or `stories/` must choose exactly one fixture entrypoint shape: `fixtures.ts`, `fixtures.tsx`, or `fixtures/`. |
|
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ const DEFAULT_OXLINT_CONFIG = defineConfig({
|
|
|
26
26
|
"**/.vitepress/cache",
|
|
27
27
|
"**/.vitepress/dist",
|
|
28
28
|
],
|
|
29
|
-
plugins: ["unicorn", "typescript", "oxc", "react", "
|
|
29
|
+
plugins: ["unicorn", "typescript", "oxc", "react", "jest"],
|
|
30
30
|
jsPlugins: [
|
|
31
31
|
{
|
|
32
32
|
name: "@alexgorbatchev",
|
|
@@ -47,6 +47,7 @@ const DEFAULT_OXLINT_CONFIG = defineConfig({
|
|
|
47
47
|
files: ["**/*.{ts,tsx,mts,cts}"],
|
|
48
48
|
rules: {
|
|
49
49
|
"@alexgorbatchev/interface-naming-convention": "error",
|
|
50
|
+
"@alexgorbatchev/no-i-prefixed-type-aliases": "error",
|
|
50
51
|
"@alexgorbatchev/no-inline-type-expressions": "error",
|
|
51
52
|
"@alexgorbatchev/require-template-indent": "error",
|
|
52
53
|
},
|
package/src/oxlint/plugin.ts
CHANGED
|
@@ -13,6 +13,7 @@ import noTypeImportsFromConstantsRule from "./rules/no-type-imports-from-constan
|
|
|
13
13
|
import noTypeExportsFromConstantsRule from "./rules/no-type-exports-from-constants.ts";
|
|
14
14
|
import noValueExportsFromTypesRule from "./rules/no-value-exports-from-types.ts";
|
|
15
15
|
import interfaceNamingConventionRule from "./rules/interface-naming-convention.ts";
|
|
16
|
+
import noIPrefixedTypeAliasesRule from "./rules/no-i-prefixed-type-aliases.ts";
|
|
16
17
|
import noInlineTypeExpressionsRule from "./rules/no-inline-type-expressions.ts";
|
|
17
18
|
import componentFileLocationConventionRule from "./rules/component-file-location-convention.ts";
|
|
18
19
|
import componentDirectoryFileConventionRule from "./rules/component-directory-file-convention.ts";
|
|
@@ -59,6 +60,7 @@ const plugin = {
|
|
|
59
60
|
"no-type-exports-from-constants": noTypeExportsFromConstantsRule,
|
|
60
61
|
"no-value-exports-from-types": noValueExportsFromTypesRule,
|
|
61
62
|
"interface-naming-convention": interfaceNamingConventionRule,
|
|
63
|
+
"no-i-prefixed-type-aliases": noIPrefixedTypeAliasesRule,
|
|
62
64
|
"no-inline-type-expressions": noInlineTypeExpressionsRule,
|
|
63
65
|
"component-file-location-convention": componentFileLocationConventionRule,
|
|
64
66
|
"component-directory-file-convention": componentDirectoryFileConventionRule,
|
|
@@ -187,6 +187,10 @@ function readExpectedComponentNameFromFilename(filename: string): string | null
|
|
|
187
187
|
.join("");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
function readInvalidFilenameReportNode(program: AstProgram): TSESTree.Node {
|
|
191
|
+
return program.body[0] ?? program;
|
|
192
|
+
}
|
|
193
|
+
|
|
190
194
|
const componentFileNamingConventionRule: RuleModule = {
|
|
191
195
|
meta: {
|
|
192
196
|
type: "problem" as const,
|
|
@@ -197,7 +201,7 @@ const componentFileNamingConventionRule: RuleModule = {
|
|
|
197
201
|
schema: [],
|
|
198
202
|
messages: {
|
|
199
203
|
invalidComponentFileName:
|
|
200
|
-
'Rename this file to
|
|
204
|
+
'Rename this file so its basename can map deterministically to the exported component name. Use "ComponentName.tsx" or "component-name.tsx"; if this is not a component ownership file and does not need JSX syntax, rename it to a ".ts" file instead.',
|
|
201
205
|
invalidComponentExportName:
|
|
202
206
|
"Rename the exported component to PascalCase. Component ownership files must export a PascalCase component name.",
|
|
203
207
|
mismatchedComponentFileName:
|
|
@@ -224,7 +228,7 @@ const componentFileNamingConventionRule: RuleModule = {
|
|
|
224
228
|
const expectedComponentName = readExpectedComponentNameFromFilename(context.filename);
|
|
225
229
|
if (!expectedComponentName) {
|
|
226
230
|
context.report({
|
|
227
|
-
node,
|
|
231
|
+
node: readInvalidFilenameReportNode(node),
|
|
228
232
|
messageId: "invalidComponentFileName",
|
|
229
233
|
});
|
|
230
234
|
return;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isExemptSupportBasename,
|
|
8
8
|
isInStoriesDirectory,
|
|
9
9
|
isInTestsDirectory,
|
|
10
|
+
readAbbreviatedSiblingDirectoryPath,
|
|
10
11
|
} from "./helpers.ts";
|
|
11
12
|
|
|
12
13
|
function readRequiredStoriesDirectoryPath(filename: string): string {
|
|
@@ -67,12 +68,12 @@ const componentStoryFileConventionRule: RuleModule = {
|
|
|
67
68
|
type: "problem" as const,
|
|
68
69
|
docs: {
|
|
69
70
|
description:
|
|
70
|
-
'Require every component ownership file to have a matching "basename.stories.tsx" file
|
|
71
|
+
'Require every component ownership file to have a matching "basename.stories.tsx" file under a sibling "stories/" directory',
|
|
71
72
|
},
|
|
72
73
|
schema: [],
|
|
73
74
|
messages: {
|
|
74
75
|
missingComponentStoryFile:
|
|
75
|
-
'
|
|
76
|
+
'Create "{{ requiredStoryFileName }}" under "{{ requiredStoriesDirectoryPath }}". Component ownership files must keep their Storybook coverage under a sibling "stories/" directory.',
|
|
76
77
|
},
|
|
77
78
|
},
|
|
78
79
|
create(context) {
|
|
@@ -87,6 +88,7 @@ const componentStoryFileConventionRule: RuleModule = {
|
|
|
87
88
|
return {
|
|
88
89
|
Program(node: AstProgram) {
|
|
89
90
|
const requiredStoriesDirectoryPath = readRequiredStoriesDirectoryPath(context.filename);
|
|
91
|
+
const displayedStoriesDirectoryPath = readAbbreviatedSiblingDirectoryPath(context.filename, "stories");
|
|
90
92
|
const requiredStoryFileName = readRequiredStoryFileName(context.filename);
|
|
91
93
|
if (findDescendantFilePath(requiredStoriesDirectoryPath, requiredStoryFileName)) {
|
|
92
94
|
return;
|
|
@@ -96,7 +98,7 @@ const componentStoryFileConventionRule: RuleModule = {
|
|
|
96
98
|
node: readReportNode(node),
|
|
97
99
|
messageId: "missingComponentStoryFile",
|
|
98
100
|
data: {
|
|
99
|
-
requiredStoriesDirectoryPath,
|
|
101
|
+
requiredStoriesDirectoryPath: displayedStoriesDirectoryPath,
|
|
100
102
|
requiredStoryFileName,
|
|
101
103
|
},
|
|
102
104
|
});
|
|
@@ -74,6 +74,16 @@ export function readPathFromDirectory(filename: string, expectedDirectoryName: s
|
|
|
74
74
|
return pathSegments.slice(directoryIndex + 1).join("/");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function readAbbreviatedPath(path: string, segmentCount = 3): string {
|
|
78
|
+
const displayedPathSegments = getPathSegments(path).slice(-segmentCount);
|
|
79
|
+
|
|
80
|
+
return `.../${displayedPathSegments.join("/")}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readAbbreviatedSiblingDirectoryPath(filename: string, siblingDirectoryName: string): string {
|
|
84
|
+
return readAbbreviatedPath(`${dirname(filename)}/${siblingDirectoryName}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
export function readPathFromFirstMatchingDirectory(
|
|
78
88
|
filename: string,
|
|
79
89
|
expectedDirectoryNames: DirectoryNames,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { AstProgram, RuleModule } from "./types.ts";
|
|
2
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
findDescendantFilePath,
|
|
5
6
|
getBaseName,
|
|
6
7
|
getFilenameWithoutExtension,
|
|
7
8
|
isExemptSupportBasename,
|
|
9
|
+
readAbbreviatedSiblingDirectoryPath,
|
|
8
10
|
} from "./helpers.ts";
|
|
9
11
|
|
|
10
12
|
function readRequiredTestsDirectoryPath(filename: string): string {
|
|
@@ -18,17 +20,21 @@ function readRequiredHookTestFileName(filename: string): string {
|
|
|
18
20
|
return `${sourceBaseName}${testExtension}`;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function readReportNode(program: AstProgram): TSESTree.Node {
|
|
24
|
+
return program.body[0] ?? program;
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
const hookTestFileConventionRule: RuleModule = {
|
|
22
28
|
meta: {
|
|
23
29
|
type: "problem" as const,
|
|
24
30
|
docs: {
|
|
25
31
|
description:
|
|
26
|
-
'Require every hook ownership file to have a matching "basename.test.ts" or ".test.tsx" file
|
|
32
|
+
'Require every hook ownership file to have a matching "basename.test.ts" or ".test.tsx" file under a sibling "__tests__/" directory',
|
|
27
33
|
},
|
|
28
34
|
schema: [],
|
|
29
35
|
messages: {
|
|
30
36
|
missingHookTestFile:
|
|
31
|
-
'
|
|
37
|
+
'Create "{{ requiredTestFileName }}" under "{{ requiredTestsDirectoryPath }}". Hook ownership files must keep their tests under a sibling "__tests__/" directory.',
|
|
32
38
|
},
|
|
33
39
|
},
|
|
34
40
|
create(context) {
|
|
@@ -39,17 +45,18 @@ const hookTestFileConventionRule: RuleModule = {
|
|
|
39
45
|
return {
|
|
40
46
|
Program(node: AstProgram) {
|
|
41
47
|
const requiredTestsDirectoryPath = readRequiredTestsDirectoryPath(context.filename);
|
|
48
|
+
const displayedTestsDirectoryPath = readAbbreviatedSiblingDirectoryPath(context.filename, "__tests__");
|
|
42
49
|
const requiredTestFileName = readRequiredHookTestFileName(context.filename);
|
|
43
50
|
if (findDescendantFilePath(requiredTestsDirectoryPath, requiredTestFileName)) {
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
context.report({
|
|
48
|
-
node,
|
|
55
|
+
node: readReportNode(node),
|
|
49
56
|
messageId: "missingHookTestFile",
|
|
50
57
|
data: {
|
|
51
58
|
requiredTestFileName,
|
|
52
|
-
requiredTestsDirectoryPath,
|
|
59
|
+
requiredTestsDirectoryPath: displayedTestsDirectoryPath,
|
|
53
60
|
},
|
|
54
61
|
});
|
|
55
62
|
},
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RuleModule } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
function readSuggestedTypeAliasName(typeAliasName: string): string {
|
|
4
|
+
return typeAliasName.slice(1);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function hasBlockedTypeAliasPrefix(typeAliasName: string): boolean {
|
|
8
|
+
return /^I[A-Z]/u.test(typeAliasName);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const noIPrefixedTypeAliasesRule: RuleModule = {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem" as const,
|
|
14
|
+
docs: {
|
|
15
|
+
description:
|
|
16
|
+
'Disallow repository-owned type alias names that start with an "I" followed by another capital letter',
|
|
17
|
+
},
|
|
18
|
+
schema: [],
|
|
19
|
+
messages: {
|
|
20
|
+
unexpectedTypeAliasName:
|
|
21
|
+
'Rename type alias "{{ name }}" to remove the "I" prefix, for example "{{ suggestedName }}". Repository-owned type aliases must not use the interface-style "I[A-Z]" prefix.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
create(context) {
|
|
25
|
+
return {
|
|
26
|
+
TSTypeAliasDeclaration(node) {
|
|
27
|
+
const typeAliasName = node.id.name;
|
|
28
|
+
if (!hasBlockedTypeAliasPrefix(typeAliasName)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
context.report({
|
|
33
|
+
node: node.id,
|
|
34
|
+
messageId: "unexpectedTypeAliasName",
|
|
35
|
+
data: {
|
|
36
|
+
name: typeAliasName,
|
|
37
|
+
suggestedName: readSuggestedTypeAliasName(typeAliasName),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default noIPrefixedTypeAliasesRule;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
-
import type { RuleModule } from "./types.ts";
|
|
2
|
+
import type { RuleFixer, RuleModule } from "./types.ts";
|
|
3
3
|
|
|
4
|
-
function
|
|
4
|
+
function readIndent(line: string): string {
|
|
5
5
|
const indentMatch = line.match(/^[ \t]*/u);
|
|
6
|
-
return indentMatch ? indentMatch[0]
|
|
6
|
+
return indentMatch ? indentMatch[0] : "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readIndentSize(line: string): number {
|
|
10
|
+
return readIndent(line).length;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
function readMinimumContentIndent(content: string): number {
|
|
@@ -21,8 +25,8 @@ function readMinimumContentIndent(content: string): number {
|
|
|
21
25
|
return Number.isFinite(minimumIndent) ? minimumIndent : 0;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
function readTemplateContent(node: TSESTree.TemplateLiteral): string {
|
|
25
|
-
return node.
|
|
28
|
+
function readTemplateContent(sourceText: string, node: TSESTree.TemplateLiteral): string {
|
|
29
|
+
return sourceText.slice(node.range[0] + 1, node.range[1] - 1);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
function startsWithNewline(templateContent: string): boolean {
|
|
@@ -33,6 +37,32 @@ function hasNonEmptyContent(templateContent: string): boolean {
|
|
|
33
37
|
return templateContent.replace(/^\n/u, "").trim().length > 0;
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
function readFixedTemplateContent(templateContent: string, indentPrefix: string): string {
|
|
41
|
+
const fixedContentLines = templateContent
|
|
42
|
+
.replace(/^\n/u, "")
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((contentLine) => {
|
|
45
|
+
if (contentLine.trim().length === 0) {
|
|
46
|
+
return contentLine;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return `${indentPrefix}${contentLine}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return `\n${fixedContentLines.join("\n")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readTemplateIndentFix(
|
|
56
|
+
fixer: RuleFixer,
|
|
57
|
+
node: TSESTree.TemplateLiteral,
|
|
58
|
+
templateContent: string,
|
|
59
|
+
indentPrefix: string,
|
|
60
|
+
) {
|
|
61
|
+
const fixedTemplateContent = readFixedTemplateContent(templateContent, indentPrefix);
|
|
62
|
+
|
|
63
|
+
return fixer.replaceTextRange([node.range[0] + 1, node.range[1] - 1], fixedTemplateContent);
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
const requireTemplateIndentRule: RuleModule = {
|
|
37
67
|
meta: {
|
|
38
68
|
type: "problem" as const,
|
|
@@ -40,17 +70,19 @@ const requireTemplateIndentRule: RuleModule = {
|
|
|
40
70
|
description: "Require multiline template literals to keep their content indented with the surrounding code",
|
|
41
71
|
},
|
|
42
72
|
schema: [],
|
|
73
|
+
fixable: "code" as const,
|
|
43
74
|
messages: {
|
|
44
75
|
badIndent:
|
|
45
|
-
|
|
76
|
+
'Indent this multiline template literal to match the surrounding code. If indentation is significant, normalize the string explicitly with "@alexgorbatchev/dedent-string" instead of relying on under-indented source text.',
|
|
46
77
|
},
|
|
47
78
|
},
|
|
48
79
|
create(context) {
|
|
49
|
-
const
|
|
80
|
+
const sourceCode = context.getSourceCode?.() ?? context.sourceCode;
|
|
81
|
+
const sourceText = sourceCode.getText();
|
|
50
82
|
|
|
51
83
|
return {
|
|
52
84
|
TemplateLiteral(node) {
|
|
53
|
-
const templateContent = readTemplateContent(node);
|
|
85
|
+
const templateContent = readTemplateContent(sourceText, node);
|
|
54
86
|
if (!startsWithNewline(templateContent) || !hasNonEmptyContent(templateContent)) {
|
|
55
87
|
return;
|
|
56
88
|
}
|
|
@@ -60,7 +92,7 @@ const requireTemplateIndentRule: RuleModule = {
|
|
|
60
92
|
return;
|
|
61
93
|
}
|
|
62
94
|
|
|
63
|
-
const sourceLine =
|
|
95
|
+
const sourceLine = sourceCode.getLines()[startLine - 1];
|
|
64
96
|
if (!sourceLine) {
|
|
65
97
|
return;
|
|
66
98
|
}
|
|
@@ -71,9 +103,14 @@ const requireTemplateIndentRule: RuleModule = {
|
|
|
71
103
|
return;
|
|
72
104
|
}
|
|
73
105
|
|
|
106
|
+
const indentPrefix = readIndent(sourceLine).slice(0, lineIndent - contentIndent);
|
|
107
|
+
|
|
74
108
|
context.report({
|
|
75
109
|
node,
|
|
76
110
|
messageId: "badIndent",
|
|
111
|
+
fix(fixer) {
|
|
112
|
+
return readTemplateIndentFix(fixer, node, templateContent, indentPrefix);
|
|
113
|
+
},
|
|
77
114
|
});
|
|
78
115
|
},
|
|
79
116
|
};
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
+
import type { AstProgram, RuleModule } from "./types.ts";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getStorySourceBaseName,
|
|
7
|
+
readAbbreviatedPath,
|
|
8
|
+
readPathFromStoriesDirectory,
|
|
9
|
+
readRootPathBeforeDirectory,
|
|
10
|
+
} from "./helpers.ts";
|
|
5
11
|
|
|
6
12
|
function readRequiredSiblingComponentFilePath(filename: string): string | null {
|
|
7
13
|
const storySourceBaseName = getStorySourceBaseName(filename);
|
|
@@ -17,28 +23,33 @@ function readRequiredSiblingComponentFilePath(filename: string): string | null {
|
|
|
17
23
|
return join(siblingDirectoryPath, `${storySourceBaseName}.tsx`);
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
function readReportNode(program: AstProgram): TSESTree.Node {
|
|
27
|
+
return program.body[0] ?? program;
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
const storyFileLocationConventionRule: RuleModule = {
|
|
21
31
|
meta: {
|
|
22
32
|
type: "problem" as const,
|
|
23
33
|
docs: {
|
|
24
34
|
description:
|
|
25
|
-
'Require Storybook files to live
|
|
35
|
+
'Require Storybook files to live under a sibling "stories/" directory and match a sibling component ownership file basename',
|
|
26
36
|
},
|
|
27
37
|
schema: [],
|
|
28
38
|
messages: {
|
|
29
39
|
invalidStoryFileLocation:
|
|
30
40
|
'Move this story file under a "stories/" directory. Storybook files must not live outside a sibling "stories/" tree.',
|
|
31
41
|
missingSiblingComponent:
|
|
32
|
-
'Rename or move this story so it matches an existing sibling component ownership file.
|
|
42
|
+
'Rename or move this story so it matches an existing sibling component ownership file. "{{ requiredComponentFilePath }}" must exist for this story file.',
|
|
33
43
|
},
|
|
34
44
|
},
|
|
35
45
|
create(context) {
|
|
36
46
|
return {
|
|
37
|
-
Program(node) {
|
|
47
|
+
Program(node: AstProgram) {
|
|
48
|
+
const reportNode = readReportNode(node);
|
|
38
49
|
const relativeStoryPath = readPathFromStoriesDirectory(context.filename);
|
|
39
50
|
if (relativeStoryPath === null) {
|
|
40
51
|
context.report({
|
|
41
|
-
node,
|
|
52
|
+
node: reportNode,
|
|
42
53
|
messageId: "invalidStoryFileLocation",
|
|
43
54
|
});
|
|
44
55
|
return;
|
|
@@ -50,10 +61,10 @@ const storyFileLocationConventionRule: RuleModule = {
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
context.report({
|
|
53
|
-
node,
|
|
64
|
+
node: reportNode,
|
|
54
65
|
messageId: "missingSiblingComponent",
|
|
55
66
|
data: {
|
|
56
|
-
requiredComponentFilePath,
|
|
67
|
+
requiredComponentFilePath: readAbbreviatedPath(requiredComponentFilePath),
|
|
57
68
|
},
|
|
58
69
|
});
|
|
59
70
|
},
|
|
@@ -9,7 +9,7 @@ const testIdNamingConventionRule: RuleModule = {
|
|
|
9
9
|
},
|
|
10
10
|
messages: {
|
|
11
11
|
invalidTestId:
|
|
12
|
-
'Rename {{ attributeName }} to "{{ componentName }}" on the component root, or to "{{ componentName }}--thing" on child elements.
|
|
12
|
+
'Rename {{ attributeName }} "{{ candidate }}" to "{{ componentName }}" on the component root, or to "{{ componentName }}--thing" on child elements.',
|
|
13
13
|
},
|
|
14
14
|
schema: [],
|
|
15
15
|
fixable: "code" as const,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { applyTextEdits, readUpdatedContent } from "./applyTextEdits.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type { FileMove, TextEdit } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
function readTextEditsByFilePath(textEdits: readonly
|
|
7
|
-
const textEditsByFilePath = new Map<string,
|
|
6
|
+
function readTextEditsByFilePath(textEdits: readonly TextEdit[]): Map<string, readonly TextEdit[]> {
|
|
7
|
+
const textEditsByFilePath = new Map<string, TextEdit[]>();
|
|
8
8
|
|
|
9
9
|
for (const textEdit of textEdits) {
|
|
10
10
|
const existingTextEdits = textEditsByFilePath.get(textEdit.filePath);
|
|
@@ -19,7 +19,7 @@ function readTextEditsByFilePath(textEdits: readonly ITextEdit[]): Map<string, r
|
|
|
19
19
|
return new Map(textEditsByFilePath);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function assertMoveFileOperationsAreSafe(fileMoves: readonly
|
|
22
|
+
function assertMoveFileOperationsAreSafe(fileMoves: readonly FileMove[]): void {
|
|
23
23
|
const sourceFilePathSet = new Set<string>();
|
|
24
24
|
const destinationFilePathSet = new Set<string>();
|
|
25
25
|
|
|
@@ -41,7 +41,7 @@ function assertMoveFileOperationsAreSafe(fileMoves: readonly IFileMove[]): void
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function applyMovedFile(fileMove:
|
|
44
|
+
function applyMovedFile(fileMove: FileMove, textEdits: readonly TextEdit[]): void {
|
|
45
45
|
if (!existsSync(fileMove.sourceFilePath)) {
|
|
46
46
|
throw new Error(`Cannot move a missing file: ${fileMove.sourceFilePath}`);
|
|
47
47
|
}
|
|
@@ -58,7 +58,7 @@ function applyMovedFile(fileMove: IFileMove, textEdits: readonly ITextEdit[]): v
|
|
|
58
58
|
rmSync(fileMove.sourceFilePath);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export function applyFileChanges(textEdits: readonly
|
|
61
|
+
export function applyFileChanges(textEdits: readonly TextEdit[], fileMoves: readonly FileMove[]): readonly string[] {
|
|
62
62
|
assertMoveFileOperationsAreSafe(fileMoves);
|
|
63
63
|
|
|
64
64
|
const changedFilePathSet = new Set<string>();
|