@alexgorbatchev/typescript-ai-policy 0.1.0

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 (62) hide show
  1. package/README.md +223 -0
  2. package/package.json +60 -0
  3. package/src/oxfmt/createOxfmtConfig.ts +26 -0
  4. package/src/oxlint/assertNoRuleCollisions.ts +40 -0
  5. package/src/oxlint/createOxlintConfig.ts +161 -0
  6. package/src/oxlint/oxlint.config.ts +3 -0
  7. package/src/oxlint/plugin.ts +90 -0
  8. package/src/oxlint/rules/component-directory-file-convention.ts +65 -0
  9. package/src/oxlint/rules/component-file-contract.ts +328 -0
  10. package/src/oxlint/rules/component-file-location-convention.ts +43 -0
  11. package/src/oxlint/rules/component-file-naming-convention.ts +260 -0
  12. package/src/oxlint/rules/component-story-file-convention.ts +108 -0
  13. package/src/oxlint/rules/fixture-export-naming-convention.ts +72 -0
  14. package/src/oxlint/rules/fixture-export-type-contract.ts +264 -0
  15. package/src/oxlint/rules/fixture-file-contract.ts +91 -0
  16. package/src/oxlint/rules/fixture-import-path-convention.ts +125 -0
  17. package/src/oxlint/rules/helpers.ts +544 -0
  18. package/src/oxlint/rules/hook-export-location-convention.ts +169 -0
  19. package/src/oxlint/rules/hook-file-contract.ts +179 -0
  20. package/src/oxlint/rules/hook-file-naming-convention.ts +151 -0
  21. package/src/oxlint/rules/hook-test-file-convention.ts +60 -0
  22. package/src/oxlint/rules/hooks-directory-file-convention.ts +75 -0
  23. package/src/oxlint/rules/index-file-contract.ts +177 -0
  24. package/src/oxlint/rules/interface-naming-convention.ts +72 -0
  25. package/src/oxlint/rules/no-conditional-logic-in-tests.ts +53 -0
  26. package/src/oxlint/rules/no-fixture-exports-outside-fixture-entrypoint.ts +68 -0
  27. package/src/oxlint/rules/no-imports-from-tests-directory.ts +114 -0
  28. package/src/oxlint/rules/no-inline-fixture-bindings-in-tests.ts +54 -0
  29. package/src/oxlint/rules/no-inline-type-expressions.ts +169 -0
  30. package/src/oxlint/rules/no-local-type-declarations-in-fixture-files.ts +55 -0
  31. package/src/oxlint/rules/no-module-mocking.ts +85 -0
  32. package/src/oxlint/rules/no-non-running-tests.ts +72 -0
  33. package/src/oxlint/rules/no-react-create-element.ts +59 -0
  34. package/src/oxlint/rules/no-test-file-exports.ts +52 -0
  35. package/src/oxlint/rules/no-throw-in-tests.ts +40 -0
  36. package/src/oxlint/rules/no-type-exports-from-constants.ts +97 -0
  37. package/src/oxlint/rules/no-type-imports-from-constants.ts +73 -0
  38. package/src/oxlint/rules/no-value-exports-from-types.ts +115 -0
  39. package/src/oxlint/rules/require-component-root-testid.ts +547 -0
  40. package/src/oxlint/rules/require-template-indent.ts +83 -0
  41. package/src/oxlint/rules/single-fixture-entrypoint.ts +142 -0
  42. package/src/oxlint/rules/stories-directory-file-convention.ts +55 -0
  43. package/src/oxlint/rules/story-export-contract.ts +343 -0
  44. package/src/oxlint/rules/story-file-location-convention.ts +64 -0
  45. package/src/oxlint/rules/story-meta-type-annotation.ts +129 -0
  46. package/src/oxlint/rules/test-file-location-convention.ts +115 -0
  47. package/src/oxlint/rules/testid-naming-convention.ts +63 -0
  48. package/src/oxlint/rules/tests-directory-file-convention.ts +55 -0
  49. package/src/oxlint/rules/types.ts +45 -0
  50. package/src/semantic-fixes/applyFileChanges.ts +81 -0
  51. package/src/semantic-fixes/applySemanticFixes.ts +239 -0
  52. package/src/semantic-fixes/applyTextEdits.ts +164 -0
  53. package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +439 -0
  54. package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +251 -0
  55. package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +132 -0
  56. package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +52 -0
  57. package/src/semantic-fixes/readMovedFileTextEdits.ts +150 -0
  58. package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +38 -0
  59. package/src/semantic-fixes/runApplySemanticFixes.ts +120 -0
  60. package/src/semantic-fixes/runOxlintJson.ts +139 -0
  61. package/src/semantic-fixes/types.ts +163 -0
  62. package/src/shared/mergeConfig.ts +38 -0
@@ -0,0 +1,544 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import type { NodeWithParent, TSESTree } from "@typescript-eslint/types";
4
+ import type {
5
+ AstClassLike,
6
+ AstDeclarationWithIdentifiers,
7
+ AstDestructuringPattern,
8
+ AstFunctionLike,
9
+ AstNode,
10
+ AstTypeDeclaration,
11
+ } from "./types.ts";
12
+
13
+ const NON_OWNERSHIP_SUPPORT_BASENAMES = new Set(["index", "types", "constants", "helpers"]);
14
+ const STRICT_AREA_ALLOWED_SUPPORT_FILES = new Set(["index.ts", "types.ts"]);
15
+
16
+ type DirectoryNames = ReadonlySet<string> | readonly string[];
17
+ type FirstMatchingDirectoryResult = {
18
+ directoryName: string;
19
+ relativePath: string;
20
+ };
21
+
22
+ export function normalizeFilename(filename: string): string {
23
+ return filename.replaceAll("\\", "/");
24
+ }
25
+
26
+ export function getPathSegments(filename: string): string[] {
27
+ return normalizeFilename(filename).split("/").filter(Boolean);
28
+ }
29
+
30
+ export function getBaseName(filename: string): string {
31
+ const normalizedFilename = normalizeFilename(filename);
32
+
33
+ return normalizedFilename.split("/").pop() ?? "";
34
+ }
35
+
36
+ export function getFilenameWithoutExtension(filename: string): string {
37
+ const baseName = getBaseName(filename);
38
+ const extensionIndex = baseName.lastIndexOf(".");
39
+
40
+ return extensionIndex === -1 ? baseName : baseName.slice(0, extensionIndex);
41
+ }
42
+
43
+ export function getExtension(filename: string): string {
44
+ const baseName = getBaseName(filename);
45
+ const extensionIndex = baseName.lastIndexOf(".");
46
+
47
+ return extensionIndex === -1 ? "" : baseName.slice(extensionIndex);
48
+ }
49
+
50
+ export function hasBaseName(path: string, expectedBaseName: string): boolean {
51
+ const baseName = getBaseName(path);
52
+
53
+ return (
54
+ baseName === expectedBaseName ||
55
+ getFilenameWithoutExtension(baseName) === expectedBaseName ||
56
+ baseName === `${expectedBaseName}.d.ts` ||
57
+ baseName === `${expectedBaseName}.d.tsx` ||
58
+ baseName === `${expectedBaseName}.d.mts` ||
59
+ baseName === `${expectedBaseName}.d.cts`
60
+ );
61
+ }
62
+
63
+ export function hasPathSegment(filename: string, expectedSegment: string): boolean {
64
+ return getPathSegments(filename).includes(expectedSegment);
65
+ }
66
+
67
+ export function readPathFromDirectory(filename: string, expectedDirectoryName: string): string | null {
68
+ const pathSegments = getPathSegments(filename);
69
+ const directoryIndex = pathSegments.indexOf(expectedDirectoryName);
70
+ if (directoryIndex === -1) {
71
+ return null;
72
+ }
73
+
74
+ return pathSegments.slice(directoryIndex + 1).join("/");
75
+ }
76
+
77
+ export function readPathFromFirstMatchingDirectory(
78
+ filename: string,
79
+ expectedDirectoryNames: DirectoryNames,
80
+ ): FirstMatchingDirectoryResult | null {
81
+ const expectedDirectoryNameSet =
82
+ expectedDirectoryNames instanceof Set ? expectedDirectoryNames : new Set(expectedDirectoryNames);
83
+ const pathSegments = getPathSegments(filename);
84
+
85
+ for (let directoryIndex = 0; directoryIndex < pathSegments.length; directoryIndex += 1) {
86
+ const pathSegment = pathSegments[directoryIndex];
87
+ if (pathSegment === undefined || !expectedDirectoryNameSet.has(pathSegment)) {
88
+ continue;
89
+ }
90
+
91
+ return {
92
+ directoryName: pathSegment,
93
+ relativePath: pathSegments.slice(directoryIndex + 1).join("/"),
94
+ };
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ export function isDirectChildOfDirectory(filename: string, expectedDirectoryName: string): boolean {
101
+ const relativePath = readPathFromDirectory(filename, expectedDirectoryName);
102
+
103
+ return relativePath !== null && relativePath !== "" && !relativePath.includes("/");
104
+ }
105
+
106
+ export function isDirectChildOfAnyDirectory(filename: string, expectedDirectoryNames: DirectoryNames): boolean {
107
+ const expectedDirectoryNameSet =
108
+ expectedDirectoryNames instanceof Set ? expectedDirectoryNames : new Set(expectedDirectoryNames);
109
+ const pathSegments = getPathSegments(filename);
110
+ if (pathSegments.length < 2) {
111
+ return false;
112
+ }
113
+
114
+ const parentPathSegment = pathSegments[pathSegments.length - 2];
115
+ return parentPathSegment !== undefined && expectedDirectoryNameSet.has(parentPathSegment);
116
+ }
117
+
118
+ export function isExemptSupportBasename(filename: string): boolean {
119
+ return NON_OWNERSHIP_SUPPORT_BASENAMES.has(getFilenameWithoutExtension(filename));
120
+ }
121
+
122
+ export function isStrictAreaAllowedSupportFile(filename: string): boolean {
123
+ return STRICT_AREA_ALLOWED_SUPPORT_FILES.has(getBaseName(filename));
124
+ }
125
+
126
+ export function getComponentNameFromAncestors(node: NodeWithParent): string | null {
127
+ let current: TSESTree.Node | undefined = node;
128
+ let componentName: string | null = null;
129
+
130
+ while (current) {
131
+ if (current.type === "FunctionDeclaration" && current.id) {
132
+ componentName = current.id.name;
133
+ }
134
+
135
+ if (current.type === "VariableDeclarator" && current.id.type === "Identifier") {
136
+ componentName = current.id.name;
137
+ }
138
+
139
+ const parentNode: unknown = Reflect.get(current, "parent");
140
+ current = isAstNode(parentNode) ? parentNode : undefined;
141
+ }
142
+
143
+ return componentName;
144
+ }
145
+
146
+ export function isTestsDirectoryPath(path: string): boolean {
147
+ const normalizedPath = normalizeFilename(path);
148
+
149
+ return /(^|\/)__tests__(\/|$)/u.test(normalizedPath);
150
+ }
151
+
152
+ export function isInTestsDirectory(filename: string): boolean {
153
+ return isTestsDirectoryPath(filename);
154
+ }
155
+
156
+ export function readPathFromTestsDirectory(filename: string): string | null {
157
+ const normalizedFilename = normalizeFilename(filename);
158
+
159
+ if (normalizedFilename.startsWith("__tests__/")) {
160
+ return normalizedFilename.slice("__tests__/".length);
161
+ }
162
+
163
+ const testsDirectoryMarker = "/__tests__/";
164
+ const testsDirectoryIndex = normalizedFilename.indexOf(testsDirectoryMarker);
165
+ if (testsDirectoryIndex === -1) {
166
+ return null;
167
+ }
168
+
169
+ return normalizedFilename.slice(testsDirectoryIndex + testsDirectoryMarker.length);
170
+ }
171
+
172
+ export function isStoriesDirectoryPath(path: string): boolean {
173
+ const normalizedPath = normalizeFilename(path);
174
+
175
+ return /(^|\/)stories(\/|$)/u.test(normalizedPath);
176
+ }
177
+
178
+ export function isInStoriesDirectory(filename: string): boolean {
179
+ return isStoriesDirectoryPath(filename);
180
+ }
181
+
182
+ export function readPathFromStoriesDirectory(filename: string): string | null {
183
+ const normalizedFilename = normalizeFilename(filename);
184
+
185
+ if (normalizedFilename.startsWith("stories/")) {
186
+ return normalizedFilename.slice("stories/".length);
187
+ }
188
+
189
+ const storiesDirectoryMarker = "/stories/";
190
+ const storiesDirectoryIndex = normalizedFilename.indexOf(storiesDirectoryMarker);
191
+ if (storiesDirectoryIndex === -1) {
192
+ return null;
193
+ }
194
+
195
+ return normalizedFilename.slice(storiesDirectoryIndex + storiesDirectoryMarker.length);
196
+ }
197
+
198
+ export function readRootPathBeforeDirectory(filename: string, expectedDirectoryName: string): string | null {
199
+ const normalizedFilename = normalizeFilename(filename);
200
+ const directoryPrefix = `${expectedDirectoryName}/`;
201
+ const rootedDirectoryPrefix = `/${expectedDirectoryName}/`;
202
+
203
+ if (normalizedFilename.startsWith(directoryPrefix)) {
204
+ return "";
205
+ }
206
+
207
+ if (normalizedFilename.startsWith(rootedDirectoryPrefix)) {
208
+ return "/";
209
+ }
210
+
211
+ const directoryMarker = `/${expectedDirectoryName}/`;
212
+ const directoryIndex = normalizedFilename.indexOf(directoryMarker);
213
+ if (directoryIndex === -1) {
214
+ return null;
215
+ }
216
+
217
+ return normalizedFilename.slice(0, directoryIndex);
218
+ }
219
+
220
+ export function findDescendantFilePath(rootDirectoryPath: string, expectedBaseName: string): string | null {
221
+ if (!existsSync(rootDirectoryPath) || !statSync(rootDirectoryPath).isDirectory()) {
222
+ return null;
223
+ }
224
+
225
+ const directoryEntries = readdirSync(rootDirectoryPath, { withFileTypes: true });
226
+
227
+ for (const directoryEntry of directoryEntries) {
228
+ const entryPath = `${normalizeFilename(rootDirectoryPath)}/${directoryEntry.name}`;
229
+
230
+ if (directoryEntry.isDirectory()) {
231
+ const descendantFilePath = findDescendantFilePath(entryPath, expectedBaseName);
232
+ if (descendantFilePath) {
233
+ return descendantFilePath;
234
+ }
235
+
236
+ continue;
237
+ }
238
+
239
+ if (directoryEntry.isFile() && directoryEntry.name === expectedBaseName) {
240
+ return entryPath;
241
+ }
242
+ }
243
+
244
+ return null;
245
+ }
246
+
247
+ export function isStoryFile(filename: string): boolean {
248
+ const relativePath = readPathFromStoriesDirectory(filename);
249
+
250
+ return relativePath !== null && /(^|\/)[^/]+\.stories\.tsx$/u.test(relativePath);
251
+ }
252
+
253
+ export function getStorySourceBaseName(filename: string): string | null {
254
+ const fileStem = getFilenameWithoutExtension(filename);
255
+
256
+ return fileStem.endsWith(".stories") ? fileStem.slice(0, -".stories".length) : null;
257
+ }
258
+
259
+ export function readPathFromFixtureSupportDirectory(filename: string): string | null {
260
+ const testsRelativePath = readPathFromTestsDirectory(filename);
261
+ if (testsRelativePath !== null) {
262
+ return testsRelativePath;
263
+ }
264
+
265
+ return readPathFromStoriesDirectory(filename);
266
+ }
267
+
268
+ export function isFixturesFile(filename: string): boolean {
269
+ const relativePath = readPathFromFixtureSupportDirectory(filename);
270
+
271
+ return relativePath !== null && /(^|\/)fixtures\.tsx?$/u.test(relativePath);
272
+ }
273
+
274
+ export function isInFixturesArea(filename: string): boolean {
275
+ const relativePath = readPathFromFixtureSupportDirectory(filename);
276
+
277
+ return relativePath !== null && /(^|\/)fixtures(?:\/|\.tsx?$)/u.test(relativePath);
278
+ }
279
+
280
+ export function isTestFile(filename: string): boolean {
281
+ const relativePath = readPathFromTestsDirectory(filename);
282
+
283
+ return relativePath !== null && /(^|\/)[^/]+\.test\.tsx?$/u.test(relativePath);
284
+ }
285
+
286
+ export function isFixtureConsumerFile(filename: string): boolean {
287
+ return isTestFile(filename) || isStoryFile(filename);
288
+ }
289
+
290
+ export function isFixtureConstName(name: string): boolean {
291
+ return /^fixture_[a-z][A-Za-z0-9]*$/u.test(name);
292
+ }
293
+
294
+ export function isFactoryFunctionName(name: string): boolean {
295
+ return /^factory_[a-z][A-Za-z0-9]*$/u.test(name);
296
+ }
297
+
298
+ export function isFixtureLikeName(name: string): boolean {
299
+ return name.startsWith("fixture_") || name.startsWith("factory_");
300
+ }
301
+
302
+ type FixtureSupportRoot = {
303
+ directoryName: "__tests__" | "stories";
304
+ rootPath: string;
305
+ };
306
+
307
+ function readFixtureSupportRoot(filename: string): FixtureSupportRoot | null {
308
+ const testsRelativePath = readPathFromTestsDirectory(filename);
309
+ if (testsRelativePath !== null) {
310
+ return {
311
+ directoryName: "__tests__",
312
+ rootPath: readRootPathBeforeDirectory(filename, "__tests__") ?? "",
313
+ };
314
+ }
315
+
316
+ const storiesRelativePath = readPathFromStoriesDirectory(filename);
317
+ if (storiesRelativePath !== null) {
318
+ return {
319
+ directoryName: "stories",
320
+ rootPath: readRootPathBeforeDirectory(filename, "stories") ?? "",
321
+ };
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ export function isAllowedFixturesImportPath(importPath: string, consumerFilename: string): boolean {
328
+ if (!/^\.\.?(?:\/|$)/u.test(importPath)) {
329
+ return false;
330
+ }
331
+
332
+ const resolvedConsumerFilename = normalizeFilename(resolve(consumerFilename));
333
+ const resolvedImportPath = normalizeFilename(resolve(dirname(resolvedConsumerFilename), importPath));
334
+ if (getBaseName(resolvedImportPath) !== "fixtures") {
335
+ return false;
336
+ }
337
+
338
+ const consumerFixtureSupportRoot = readFixtureSupportRoot(resolvedConsumerFilename);
339
+ const importedFixtureSupportRoot = readFixtureSupportRoot(resolvedImportPath);
340
+ if (!consumerFixtureSupportRoot || !importedFixtureSupportRoot) {
341
+ return false;
342
+ }
343
+
344
+ return (
345
+ consumerFixtureSupportRoot.directoryName === importedFixtureSupportRoot.directoryName &&
346
+ consumerFixtureSupportRoot.rootPath === importedFixtureSupportRoot.rootPath
347
+ );
348
+ }
349
+
350
+ export function readPatternIdentifierNames(pattern: AstDestructuringPattern): string[] {
351
+ if (pattern.type === "Identifier") {
352
+ return [pattern.name];
353
+ }
354
+
355
+ if (pattern.type === "RestElement") {
356
+ return readPatternIdentifierNames(pattern.argument);
357
+ }
358
+
359
+ if (pattern.type === "MemberExpression") {
360
+ return [];
361
+ }
362
+
363
+ if (pattern.type === "ArrayPattern") {
364
+ return pattern.elements.flatMap((element) => {
365
+ if (!element) {
366
+ return [];
367
+ }
368
+
369
+ return readPatternIdentifierNames(element);
370
+ });
371
+ }
372
+
373
+ if (pattern.type === "ObjectPattern") {
374
+ return pattern.properties.flatMap((property) => {
375
+ if (property.type === "RestElement") {
376
+ return readPatternIdentifierNames(property.argument);
377
+ }
378
+
379
+ if (property.value.type === "AssignmentPattern") {
380
+ return readPatternIdentifierNames(property.value.left);
381
+ }
382
+
383
+ return isDestructuringPatternNode(property.value) ? readPatternIdentifierNames(property.value) : [];
384
+ });
385
+ }
386
+
387
+ if (pattern.type === "AssignmentPattern") {
388
+ return readPatternIdentifierNames(pattern.left);
389
+ }
390
+
391
+ return [];
392
+ }
393
+
394
+ export function readDeclarationIdentifierNames(declaration: AstDeclarationWithIdentifiers): string[] {
395
+ if (declaration.type === "VariableDeclaration") {
396
+ return declaration.declarations.flatMap((declarator) => readPatternIdentifierNames(declarator.id));
397
+ }
398
+
399
+ if (
400
+ (declaration.type === "TSModuleDeclaration" || declaration.type === "TSImportEqualsDeclaration") &&
401
+ declaration.id.type === "Identifier"
402
+ ) {
403
+ return [declaration.id.name];
404
+ }
405
+
406
+ if (!declaration.id || !("name" in declaration.id)) {
407
+ return [];
408
+ }
409
+
410
+ return [declaration.id.name];
411
+ }
412
+
413
+ export function readLiteralStringValue(node: TSESTree.Literal | null | undefined): string | null {
414
+ if (node?.type === "Literal" && typeof node.value === "string") {
415
+ return node.value;
416
+ }
417
+
418
+ return null;
419
+ }
420
+
421
+ function isDestructuringPatternNode(node: TSESTree.Node): node is AstDestructuringPattern {
422
+ return (
423
+ node.type === "ArrayPattern" ||
424
+ node.type === "AssignmentPattern" ||
425
+ node.type === "Identifier" ||
426
+ node.type === "MemberExpression" ||
427
+ node.type === "ObjectPattern" ||
428
+ node.type === "RestElement"
429
+ );
430
+ }
431
+
432
+ export function isTypeDeclaration(node: AstNode | null | undefined): node is AstTypeDeclaration {
433
+ return node?.type === "TSTypeAliasDeclaration" || node?.type === "TSInterfaceDeclaration";
434
+ }
435
+
436
+ export function unwrapExpression(expression: TSESTree.Expression): TSESTree.Expression {
437
+ return expression;
438
+ }
439
+
440
+ export function unwrapTypeScriptExpression(expression: TSESTree.Expression): TSESTree.Expression {
441
+ let currentExpression = unwrapExpression(expression);
442
+
443
+ while (
444
+ currentExpression.type === "TSAsExpression" ||
445
+ currentExpression.type === "TSSatisfiesExpression" ||
446
+ currentExpression.type === "TSNonNullExpression"
447
+ ) {
448
+ currentExpression = unwrapExpression(currentExpression.expression);
449
+ }
450
+
451
+ return currentExpression;
452
+ }
453
+
454
+ export function isTestIdAttributeName(attributeName: string): boolean {
455
+ return attributeName === "data-testid" || attributeName === "testId";
456
+ }
457
+
458
+ export function readJsxAttributeName(attributeName: TSESTree.JSXAttribute["name"]): string {
459
+ if (attributeName.type === "JSXIdentifier") {
460
+ return attributeName.name;
461
+ }
462
+
463
+ if (attributeName.type === "JSXNamespacedName") {
464
+ return `${attributeName.namespace.name}:${attributeName.name.name}`;
465
+ }
466
+
467
+ return "";
468
+ }
469
+
470
+ export function isAstNode(value: unknown): value is AstNode {
471
+ return (
472
+ value !== null && typeof value === "object" && "type" in value && typeof Reflect.get(value, "type") === "string"
473
+ );
474
+ }
475
+
476
+ export function readChildNodes(node: AstNode): AstNode[] {
477
+ return Object.entries(node).flatMap(([key, value]) => {
478
+ if (key === "parent" || key === "loc" || key === "range") {
479
+ return [];
480
+ }
481
+
482
+ if (Array.isArray(value)) {
483
+ return value.filter(isAstNode);
484
+ }
485
+
486
+ return isAstNode(value) ? [value] : [];
487
+ });
488
+ }
489
+
490
+ export function isNestedFunctionNode(node: AstNode): node is AstFunctionLike {
491
+ return (
492
+ node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression"
493
+ );
494
+ }
495
+
496
+ export function isNestedClassNode(node: AstNode): node is AstClassLike {
497
+ return node.type === "ClassDeclaration" || node.type === "ClassExpression";
498
+ }
499
+
500
+ export function isNullLiteral(node: TSESTree.Expression): node is TSESTree.Literal {
501
+ return node.type === "Literal" && node.value === null;
502
+ }
503
+
504
+ export function isPascalCase(value: string): boolean {
505
+ return /^[A-Z][A-Za-z0-9]*$/u.test(value);
506
+ }
507
+
508
+ function compareNamesByShortestLength(leftName: string, rightName: string): number {
509
+ const lengthDifference = leftName.length - rightName.length;
510
+ if (lengthDifference !== 0) {
511
+ return lengthDifference;
512
+ }
513
+
514
+ return leftName.localeCompare(rightName);
515
+ }
516
+
517
+ export function isMultipartComponentPartName(componentName: string, rootComponentName: string): boolean {
518
+ if (componentName === rootComponentName) {
519
+ return true;
520
+ }
521
+
522
+ if (!componentName.startsWith(rootComponentName)) {
523
+ return false;
524
+ }
525
+
526
+ const componentPartSuffix = componentName.slice(rootComponentName.length);
527
+ return /^[A-Z]/u.test(componentPartSuffix);
528
+ }
529
+
530
+ export function readMultipartComponentRootName(componentNames: readonly string[]): string | null {
531
+ if (componentNames.length < 2) {
532
+ return null;
533
+ }
534
+
535
+ const sortedComponentNames = [...componentNames].sort(compareNamesByShortestLength);
536
+ const rootComponentName = sortedComponentNames[0];
537
+ if (!rootComponentName) {
538
+ return null;
539
+ }
540
+
541
+ return componentNames.every((componentName) => isMultipartComponentPartName(componentName, rootComponentName))
542
+ ? rootComponentName
543
+ : null;
544
+ }
@@ -0,0 +1,169 @@
1
+ import type {
2
+ AstDeclarationWithIdentifiers,
3
+ AstExportNamedDeclaration,
4
+ AstExportSpecifier,
5
+ AstProgram,
6
+ AstProgramStatement,
7
+ AstVariableDeclaration,
8
+ AstVariableDeclarator,
9
+ RuleModule,
10
+ } from "./types.ts";
11
+ import {
12
+ getExtension,
13
+ getFilenameWithoutExtension,
14
+ isDirectChildOfAnyDirectory,
15
+ isStrictAreaAllowedSupportFile,
16
+ readDeclarationIdentifierNames,
17
+ readPatternIdentifierNames,
18
+ } from "./helpers.ts";
19
+
20
+ const HOOK_DIRECTORY_NAMES = new Set(["hooks"]);
21
+ const ALLOWED_HOOK_EXTENSIONS = new Set([".ts", ".tsx"]);
22
+
23
+ type HookExportEntry = {
24
+ exportedName: string;
25
+ node: AstVariableDeclarator | AstDeclarationWithIdentifiers | AstExportSpecifier;
26
+ };
27
+
28
+ function readExportedSpecifierName(specifier: AstExportSpecifier): string {
29
+ if (specifier.exported.type === "Identifier") {
30
+ return specifier.exported.name;
31
+ }
32
+
33
+ return String(specifier.exported.value);
34
+ }
35
+
36
+ function isTypeOnlyExportSpecifier(
37
+ specifier: AstExportSpecifier,
38
+ exportDeclaration: AstExportNamedDeclaration,
39
+ ): boolean {
40
+ return exportDeclaration.exportKind === "type" || specifier.exportKind === "type";
41
+ }
42
+
43
+ function readRuntimeHookExportEntries(program: AstProgram): HookExportEntry[] {
44
+ return program.body.flatMap((statement) => readStatementRuntimeHookExportEntries(statement));
45
+ }
46
+
47
+ function readStatementRuntimeHookExportEntries(statement: AstProgramStatement): HookExportEntry[] {
48
+ if (
49
+ statement.type === "ExportAllDeclaration" ||
50
+ statement.type === "ExportDefaultDeclaration" ||
51
+ statement.type === "TSExportAssignment"
52
+ ) {
53
+ return [];
54
+ }
55
+
56
+ if (statement.type !== "ExportNamedDeclaration") {
57
+ return [];
58
+ }
59
+
60
+ if (statement.declaration) {
61
+ if (
62
+ statement.exportKind === "type" ||
63
+ statement.declaration.type === "TSTypeAliasDeclaration" ||
64
+ statement.declaration.type === "TSInterfaceDeclaration" ||
65
+ statement.declaration.type === "TSModuleDeclaration"
66
+ ) {
67
+ return [];
68
+ }
69
+
70
+ return readDeclarationRuntimeHookExportEntries(statement.declaration);
71
+ }
72
+
73
+ return statement.specifiers
74
+ .filter((specifier) => !isTypeOnlyExportSpecifier(specifier, statement))
75
+ .map((specifier) => ({
76
+ exportedName: readExportedSpecifierName(specifier),
77
+ node: specifier,
78
+ }))
79
+ .filter((entry) => entry.exportedName.startsWith("use"));
80
+ }
81
+
82
+ function readDeclarationRuntimeHookExportEntries(declaration: AstDeclarationWithIdentifiers): HookExportEntry[] {
83
+ if (declaration.type === "VariableDeclaration") {
84
+ return readVariableDeclarationRuntimeHookExportEntries(declaration);
85
+ }
86
+
87
+ return readDeclarationIdentifierNames(declaration)
88
+ .filter((name) => name.startsWith("use"))
89
+ .map((name) => ({
90
+ exportedName: name,
91
+ node: declaration,
92
+ }));
93
+ }
94
+
95
+ function readVariableDeclarationRuntimeHookExportEntries(declaration: AstVariableDeclaration): HookExportEntry[] {
96
+ return declaration.declarations.flatMap((declarator) => {
97
+ const declarationNames = readPatternIdentifierNames(declarator.id);
98
+
99
+ return declarationNames
100
+ .filter((name) => name.startsWith("use"))
101
+ .map((name) => ({
102
+ exportedName: name,
103
+ node: declarator,
104
+ }));
105
+ });
106
+ }
107
+
108
+ function isCanonicalHookOwnershipFile(filename: string): boolean {
109
+ if (isStrictAreaAllowedSupportFile(filename)) {
110
+ return false;
111
+ }
112
+
113
+ if (!isDirectChildOfAnyDirectory(filename, HOOK_DIRECTORY_NAMES)) {
114
+ return false;
115
+ }
116
+
117
+ return getFilenameWithoutExtension(filename).startsWith("use");
118
+ }
119
+
120
+ function readKebabCaseHookName(name: string): string {
121
+ return name.replaceAll(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
122
+ }
123
+
124
+ const hookExportLocationConventionRule: RuleModule = {
125
+ meta: {
126
+ type: "problem" as const,
127
+ docs: {
128
+ description:
129
+ 'Require exported runtime bindings whose name starts with "use" to live in direct-child "hooks/use*.ts" or "hooks/use*.tsx" ownership files',
130
+ },
131
+ schema: [],
132
+ messages: {
133
+ misplacedHookExport:
134
+ 'Move exported hook "{{ hookName }}" into a direct-child ownership file under a "hooks/" directory. Valid filenames are "hooks/{{ camelFilename }}" or "hooks/{{ kebabFilename }}".',
135
+ },
136
+ },
137
+ create(context) {
138
+ if (
139
+ !ALLOWED_HOOK_EXTENSIONS.has(getExtension(context.filename)) ||
140
+ isStrictAreaAllowedSupportFile(context.filename)
141
+ ) {
142
+ return {};
143
+ }
144
+
145
+ if (isCanonicalHookOwnershipFile(context.filename)) {
146
+ return {};
147
+ }
148
+
149
+ return {
150
+ Program(node) {
151
+ readRuntimeHookExportEntries(node).forEach((hookExportEntry) => {
152
+ const extension = getExtension(context.filename) || ".ts";
153
+
154
+ context.report({
155
+ node: hookExportEntry.node,
156
+ messageId: "misplacedHookExport",
157
+ data: {
158
+ hookName: hookExportEntry.exportedName,
159
+ camelFilename: `${hookExportEntry.exportedName}${extension}`,
160
+ kebabFilename: `${readKebabCaseHookName(hookExportEntry.exportedName)}${extension}`,
161
+ },
162
+ });
163
+ });
164
+ },
165
+ };
166
+ },
167
+ };
168
+
169
+ export default hookExportLocationConventionRule;