@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.
Files changed (26) hide show
  1. package/README.md +39 -37
  2. package/package.json +1 -1
  3. package/src/oxlint/createOxlintConfig.ts +2 -1
  4. package/src/oxlint/plugin.ts +2 -0
  5. package/src/oxlint/rules/component-file-naming-convention.ts +6 -2
  6. package/src/oxlint/rules/component-story-file-convention.ts +5 -3
  7. package/src/oxlint/rules/helpers.ts +10 -0
  8. package/src/oxlint/rules/hook-test-file-convention.ts +11 -4
  9. package/src/oxlint/rules/no-i-prefixed-type-aliases.ts +45 -0
  10. package/src/oxlint/rules/require-template-indent.ts +46 -9
  11. package/src/oxlint/rules/story-file-location-convention.ts +19 -8
  12. package/src/oxlint/rules/testid-naming-convention.ts +1 -1
  13. package/src/semantic-fixes/applyFileChanges.ts +6 -6
  14. package/src/semantic-fixes/applySemanticFixes.ts +29 -26
  15. package/src/semantic-fixes/applyTextEdits.ts +20 -20
  16. package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +49 -49
  17. package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +41 -41
  18. package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +15 -89
  19. package/src/semantic-fixes/providers/createNoIPrefixedTypeAliasesSemanticFixProvider.ts +49 -0
  20. package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +9 -19
  21. package/src/semantic-fixes/providers/helpers.ts +117 -0
  22. package/src/semantic-fixes/readMovedFileTextEdits.ts +7 -7
  23. package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +2 -2
  24. package/src/semantic-fixes/runApplySemanticFixes.ts +5 -5
  25. package/src/semantic-fixes/runOxlintJson.ts +9 -9
  26. 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 two conservative semantic fixes:
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-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. |
216
- | `@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. |
217
- | `@alexgorbatchev/index-file-contract` | `index.ts` must stay a pure barrel: no local definitions, no side effects, only re-exports, and never `index.tsx`. |
218
- | `@alexgorbatchev/no-type-imports-from-constants` | Types must not be imported from `constants` modules, including inline `import("./constants")` type queries. |
219
- | `@alexgorbatchev/no-type-exports-from-constants` | `constants.ts` files may export runtime values only; exported types must move to `types.ts`. |
220
- | `@alexgorbatchev/no-value-exports-from-types` | `types.ts` files may export type-only API only; runtime values and value re-exports must move elsewhere. |
221
- | `@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. |
222
- | `@alexgorbatchev/fixture-file-contract` | `__tests__/fixtures.ts(x)` and `stories/fixtures.ts(x)` may export only direct named `const` fixtures and named factory functions. |
223
- | `@alexgorbatchev/fixture-export-naming-convention` | Fixture entrypoint exports must use `fixture_<lowerCamelCase>` and `factory_<lowerCamelCase>`. |
224
- | `@alexgorbatchev/fixture-export-type-contract` | Fixture entrypoint exports must declare explicit imported concrete types and must not use `any` or `unknown`. |
225
- | `@alexgorbatchev/no-fixture-exports-outside-fixture-entrypoint` | `fixture_*` and `factory_*` exports may exist only in nested `fixtures.ts(x)` entrypoints under `__tests__/` or `stories/`. |
226
- | `@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. |
227
- | `@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. |
228
- | `@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. |
229
- | `@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/`. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexgorbatchev/typescript-ai-policy",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Shared TypeScript AI policy configs and Oxlint rules",
5
5
  "homepage": "https://github.com/alexgorbatchev/typescript-ai-policy#readme",
6
6
  "repository": {
@@ -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", "jsx-a11y", "jest"],
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
  },
@@ -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 either "ComponentName.tsx" or "component-name.tsx" so its basename can map deterministically to the exported component name.',
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 somewhere under a sibling "stories/" directory',
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
- 'Add a story file named "{{ requiredStoryFileName }}" somewhere under "{{ requiredStoriesDirectoryPath }}". Component ownership files must keep their Storybook coverage under a sibling "stories/" directory.',
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 somewhere under a sibling "__tests__/" directory',
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
- 'Add a test file named "{{ requiredTestFileName }}" somewhere under "{{ requiredTestsDirectoryPath }}". Hook ownership files must keep their tests under a sibling "__tests__/" directory.',
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 readIndentSize(line: string): number {
4
+ function readIndent(line: string): string {
5
5
  const indentMatch = line.match(/^[ \t]*/u);
6
- return indentMatch ? indentMatch[0].length : 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.quasis.map((quasi) => quasi.value.raw).join("${...}");
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
- "Indent this multiline template literal to match the surrounding code. If leading whitespace is part of the intended value, normalize the string explicitly instead of relying on under-indented source text.",
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 sourceLines = context.sourceCode.lines;
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 = sourceLines[startLine - 1];
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 { RuleModule } from "./types.ts";
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 { getStorySourceBaseName, readPathFromStoriesDirectory, readRootPathBeforeDirectory } from "./helpers.ts";
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 somewhere under a sibling "stories/" directory and match a sibling component ownership file basename',
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. Expected "{{ requiredComponentFilePath }}" to exist for this story 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. Received "{{ candidate }}".',
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 { IFileMove, ITextEdit } from "./types.ts";
4
+ import type { FileMove, TextEdit } from "./types.ts";
5
5
 
6
- function readTextEditsByFilePath(textEdits: readonly ITextEdit[]): Map<string, readonly ITextEdit[]> {
7
- const textEditsByFilePath = new Map<string, ITextEdit[]>();
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 IFileMove[]): void {
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: IFileMove, textEdits: readonly ITextEdit[]): void {
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 ITextEdit[], fileMoves: readonly IFileMove[]): readonly string[] {
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>();