@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.
- package/README.md +223 -0
- package/package.json +60 -0
- package/src/oxfmt/createOxfmtConfig.ts +26 -0
- package/src/oxlint/assertNoRuleCollisions.ts +40 -0
- package/src/oxlint/createOxlintConfig.ts +161 -0
- package/src/oxlint/oxlint.config.ts +3 -0
- package/src/oxlint/plugin.ts +90 -0
- package/src/oxlint/rules/component-directory-file-convention.ts +65 -0
- package/src/oxlint/rules/component-file-contract.ts +328 -0
- package/src/oxlint/rules/component-file-location-convention.ts +43 -0
- package/src/oxlint/rules/component-file-naming-convention.ts +260 -0
- package/src/oxlint/rules/component-story-file-convention.ts +108 -0
- package/src/oxlint/rules/fixture-export-naming-convention.ts +72 -0
- package/src/oxlint/rules/fixture-export-type-contract.ts +264 -0
- package/src/oxlint/rules/fixture-file-contract.ts +91 -0
- package/src/oxlint/rules/fixture-import-path-convention.ts +125 -0
- package/src/oxlint/rules/helpers.ts +544 -0
- package/src/oxlint/rules/hook-export-location-convention.ts +169 -0
- package/src/oxlint/rules/hook-file-contract.ts +179 -0
- package/src/oxlint/rules/hook-file-naming-convention.ts +151 -0
- package/src/oxlint/rules/hook-test-file-convention.ts +60 -0
- package/src/oxlint/rules/hooks-directory-file-convention.ts +75 -0
- package/src/oxlint/rules/index-file-contract.ts +177 -0
- package/src/oxlint/rules/interface-naming-convention.ts +72 -0
- package/src/oxlint/rules/no-conditional-logic-in-tests.ts +53 -0
- package/src/oxlint/rules/no-fixture-exports-outside-fixture-entrypoint.ts +68 -0
- package/src/oxlint/rules/no-imports-from-tests-directory.ts +114 -0
- package/src/oxlint/rules/no-inline-fixture-bindings-in-tests.ts +54 -0
- package/src/oxlint/rules/no-inline-type-expressions.ts +169 -0
- package/src/oxlint/rules/no-local-type-declarations-in-fixture-files.ts +55 -0
- package/src/oxlint/rules/no-module-mocking.ts +85 -0
- package/src/oxlint/rules/no-non-running-tests.ts +72 -0
- package/src/oxlint/rules/no-react-create-element.ts +59 -0
- package/src/oxlint/rules/no-test-file-exports.ts +52 -0
- package/src/oxlint/rules/no-throw-in-tests.ts +40 -0
- package/src/oxlint/rules/no-type-exports-from-constants.ts +97 -0
- package/src/oxlint/rules/no-type-imports-from-constants.ts +73 -0
- package/src/oxlint/rules/no-value-exports-from-types.ts +115 -0
- package/src/oxlint/rules/require-component-root-testid.ts +547 -0
- package/src/oxlint/rules/require-template-indent.ts +83 -0
- package/src/oxlint/rules/single-fixture-entrypoint.ts +142 -0
- package/src/oxlint/rules/stories-directory-file-convention.ts +55 -0
- package/src/oxlint/rules/story-export-contract.ts +343 -0
- package/src/oxlint/rules/story-file-location-convention.ts +64 -0
- package/src/oxlint/rules/story-meta-type-annotation.ts +129 -0
- package/src/oxlint/rules/test-file-location-convention.ts +115 -0
- package/src/oxlint/rules/testid-naming-convention.ts +63 -0
- package/src/oxlint/rules/tests-directory-file-convention.ts +55 -0
- package/src/oxlint/rules/types.ts +45 -0
- package/src/semantic-fixes/applyFileChanges.ts +81 -0
- package/src/semantic-fixes/applySemanticFixes.ts +239 -0
- package/src/semantic-fixes/applyTextEdits.ts +164 -0
- package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +439 -0
- package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +251 -0
- package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +132 -0
- package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +52 -0
- package/src/semantic-fixes/readMovedFileTextEdits.ts +150 -0
- package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +38 -0
- package/src/semantic-fixes/runApplySemanticFixes.ts +120 -0
- package/src/semantic-fixes/runOxlintJson.ts +139 -0
- package/src/semantic-fixes/types.ts +163 -0
- package/src/shared/mergeConfig.ts +38 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { RuleModule } from "./types.ts";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { readPathFromStoriesDirectory, readPathFromTestsDirectory } from "./helpers.ts";
|
|
5
|
+
|
|
6
|
+
const FIXTURE_ENTRYPOINT_CANDIDATE_KEYS = ["fixtures.ts", "fixtures.tsx", "fixtures/"];
|
|
7
|
+
|
|
8
|
+
type FixtureDirectoryMatch = {
|
|
9
|
+
directoryLabel: string;
|
|
10
|
+
relativePath: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function readFixtureDirectoryMatch(filename: string): FixtureDirectoryMatch | null {
|
|
14
|
+
const testsRelativePath = readPathFromTestsDirectory(filename);
|
|
15
|
+
if (testsRelativePath !== null) {
|
|
16
|
+
return {
|
|
17
|
+
directoryLabel: "__tests__",
|
|
18
|
+
relativePath: testsRelativePath,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const storiesRelativePath = readPathFromStoriesDirectory(filename);
|
|
23
|
+
if (storiesRelativePath !== null) {
|
|
24
|
+
return {
|
|
25
|
+
directoryLabel: "stories",
|
|
26
|
+
relativePath: storiesRelativePath,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readFixtureEntrypointCandidateKey(filename: string): string | null {
|
|
34
|
+
const fixtureDirectoryMatch = readFixtureDirectoryMatch(filename);
|
|
35
|
+
if (!fixtureDirectoryMatch) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const relativePathSegments = fixtureDirectoryMatch.relativePath.split("/").filter(Boolean);
|
|
40
|
+
const lastPathSegment = relativePathSegments.at(-1);
|
|
41
|
+
|
|
42
|
+
if (lastPathSegment === "fixtures.ts" || lastPathSegment === "fixtures.tsx") {
|
|
43
|
+
return lastPathSegment;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (relativePathSegments.includes("fixtures")) {
|
|
47
|
+
return "fixtures/";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readFixtureSupportDirectoryPath(filename: string): string | null {
|
|
54
|
+
const fixtureDirectoryMatch = readFixtureDirectoryMatch(filename);
|
|
55
|
+
if (!fixtureDirectoryMatch) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const relativePathSegments = fixtureDirectoryMatch.relativePath.split("/").filter(Boolean);
|
|
60
|
+
const fixtureFileIndex = relativePathSegments.findLastIndex(
|
|
61
|
+
(pathSegment) => pathSegment === "fixtures.ts" || pathSegment === "fixtures.tsx",
|
|
62
|
+
);
|
|
63
|
+
const fixtureDirectoryIndex = relativePathSegments.lastIndexOf("fixtures");
|
|
64
|
+
const fixtureSegmentIndex = fixtureFileIndex === -1 ? fixtureDirectoryIndex : fixtureFileIndex;
|
|
65
|
+
if (fixtureSegmentIndex === -1) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const levelsToAscend = relativePathSegments.length - fixtureSegmentIndex;
|
|
70
|
+
|
|
71
|
+
let currentPath = filename;
|
|
72
|
+
for (let levelIndex = 0; levelIndex < levelsToAscend; levelIndex += 1) {
|
|
73
|
+
currentPath = dirname(currentPath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return currentPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readFixtureSupportDirectoryLabel(filename: string): string | null {
|
|
80
|
+
return readFixtureDirectoryMatch(filename)?.directoryLabel ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isExistingDirectory(path: string): boolean {
|
|
84
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readExistingFixtureEntrypointCandidateKeys(fixtureSupportDirectoryPath: string): string[] {
|
|
88
|
+
return FIXTURE_ENTRYPOINT_CANDIDATE_KEYS.filter((candidateKey) => {
|
|
89
|
+
if (candidateKey === "fixtures/") {
|
|
90
|
+
return isExistingDirectory(join(fixtureSupportDirectoryPath, "fixtures"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return existsSync(join(fixtureSupportDirectoryPath, candidateKey));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const singleFixtureEntrypointRule: RuleModule = {
|
|
98
|
+
meta: {
|
|
99
|
+
type: "problem" as const,
|
|
100
|
+
docs: {
|
|
101
|
+
description:
|
|
102
|
+
'Allow only one fixture entrypoint shape per fixture-support directory under "__tests__" or "stories" so imports from "./fixtures" stay unambiguous',
|
|
103
|
+
},
|
|
104
|
+
schema: [],
|
|
105
|
+
messages: {
|
|
106
|
+
conflictingFixtureEntrypoints:
|
|
107
|
+
'Keep exactly one fixture entrypoint shape in this fixture-support directory under "{{ directoryLabel }}" so "./fixtures" resolves unambiguously. Remove all but one of: {{ entries }}.',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
create(context) {
|
|
111
|
+
const currentCandidateKey = readFixtureEntrypointCandidateKey(context.filename);
|
|
112
|
+
if (!currentCandidateKey) {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fixtureSupportDirectoryPath = readFixtureSupportDirectoryPath(context.filename);
|
|
117
|
+
const directoryLabel = readFixtureSupportDirectoryLabel(context.filename);
|
|
118
|
+
if (!fixtureSupportDirectoryPath || !directoryLabel) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const existingCandidateKeys = readExistingFixtureEntrypointCandidateKeys(fixtureSupportDirectoryPath);
|
|
123
|
+
if (existingCandidateKeys.length <= 1 || currentCandidateKey !== existingCandidateKeys[0]) {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
Program(node) {
|
|
129
|
+
context.report({
|
|
130
|
+
node,
|
|
131
|
+
messageId: "conflictingFixtureEntrypoints",
|
|
132
|
+
data: {
|
|
133
|
+
directoryLabel,
|
|
134
|
+
entries: existingCandidateKeys.join(", "),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export default singleFixtureEntrypointRule;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RuleModule } from "./types.ts";
|
|
2
|
+
import { isInStoriesDirectory, readPathFromStoriesDirectory } from "./helpers.ts";
|
|
3
|
+
|
|
4
|
+
const ALLOWED_ROOT_STORY_FILES_PATTERN = /^[^/]+\.stories\.tsx$/u;
|
|
5
|
+
const ALLOWED_SUPPORT_FILES = new Set(["fixtures.ts", "fixtures.tsx", "helpers.ts", "helpers.tsx"]);
|
|
6
|
+
|
|
7
|
+
function isAllowedStoriesDirectoryPath(relativePath: string): boolean {
|
|
8
|
+
if (relativePath.startsWith("fixtures/")) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (ALLOWED_SUPPORT_FILES.has(relativePath)) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return ALLOWED_ROOT_STORY_FILES_PATTERN.test(relativePath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const storiesDirectoryFileConventionRule: RuleModule = {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem" as const,
|
|
22
|
+
docs: {
|
|
23
|
+
description: "Restrict stories directory contents to story files, helpers, and fixtures",
|
|
24
|
+
},
|
|
25
|
+
schema: [],
|
|
26
|
+
messages: {
|
|
27
|
+
invalidStoriesDirectoryFile:
|
|
28
|
+
'Move or rename "{{ relativePath }}". A "stories" directory may contain only "*.stories.tsx", "helpers.ts", "helpers.tsx", "fixtures.ts", "fixtures.tsx", or files under "fixtures/".',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
return {
|
|
33
|
+
Program(node) {
|
|
34
|
+
if (!isInStoriesDirectory(context.filename)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const relativePath = readPathFromStoriesDirectory(context.filename);
|
|
39
|
+
if (!relativePath || isAllowedStoriesDirectoryPath(relativePath)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
context.report({
|
|
44
|
+
node,
|
|
45
|
+
messageId: "invalidStoriesDirectoryFile",
|
|
46
|
+
data: {
|
|
47
|
+
relativePath,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default storiesDirectoryFileConventionRule;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type { TSESTree } from "@typescript-eslint/types";
|
|
2
|
+
import type { AstProgram, RuleModule } from "./types.ts";
|
|
3
|
+
import { getStorySourceBaseName, isPascalCase, unwrapExpression, unwrapTypeScriptExpression } from "./helpers.ts";
|
|
4
|
+
|
|
5
|
+
type StoryExportRecord = {
|
|
6
|
+
exportedName: string;
|
|
7
|
+
kind: "declaration" | "specifier";
|
|
8
|
+
localName: string;
|
|
9
|
+
node: TSESTree.ExportSpecifier | TSESTree.VariableDeclarator;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type StoryCandidateEntry = {
|
|
13
|
+
declaration: TSESTree.VariableDeclaration;
|
|
14
|
+
declarator: TSESTree.VariableDeclarator & { id: TSESTree.Identifier; init: TSESTree.Expression };
|
|
15
|
+
exports: StoryExportRecord[];
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function readDefaultExportDeclaration(program: AstProgram): TSESTree.ExportDefaultDeclaration | null {
|
|
20
|
+
return program.body.find((statement) => statement.type === "ExportDefaultDeclaration") ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readExpectedComponentNameFromStoryFilename(filename: string): string | null {
|
|
24
|
+
const storySourceBaseName = getStorySourceBaseName(filename);
|
|
25
|
+
if (!storySourceBaseName) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (isPascalCase(storySourceBaseName)) {
|
|
30
|
+
return storySourceBaseName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(storySourceBaseName)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return storySourceBaseName
|
|
38
|
+
.split("-")
|
|
39
|
+
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isStoryTypeReference(typeAnnotation: TSESTree.TypeNode | null | undefined): boolean {
|
|
44
|
+
return (
|
|
45
|
+
typeAnnotation?.type === "TSTypeReference" &&
|
|
46
|
+
typeAnnotation.typeName.type === "Identifier" &&
|
|
47
|
+
typeAnnotation.typeName.name === "Story" &&
|
|
48
|
+
!typeAnnotation.typeArguments
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStoryAssertionTypeAnnotation(expression: TSESTree.Expression): TSESTree.TypeNode | null {
|
|
53
|
+
const unwrappedExpression = unwrapExpression(expression);
|
|
54
|
+
if (unwrappedExpression.type !== "TSAsExpression" && unwrappedExpression.type !== "TSSatisfiesExpression") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return unwrappedExpression.typeAnnotation;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readStoryObjectExpression(expression: TSESTree.Expression): TSESTree.ObjectExpression | null {
|
|
62
|
+
const unwrappedExpression = unwrapTypeScriptExpression(expression);
|
|
63
|
+
|
|
64
|
+
return unwrappedExpression.type === "ObjectExpression" ? unwrappedExpression : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readPropertyName(property: TSESTree.Property): string | null {
|
|
68
|
+
if (property.computed) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (property.key.type === "Identifier") {
|
|
73
|
+
return property.key.name;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (property.key.type === "Literal" && typeof property.key.value === "string") {
|
|
77
|
+
return property.key.value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasPlayProperty(expression: TSESTree.Expression): boolean {
|
|
84
|
+
const storyObjectExpression = readStoryObjectExpression(expression);
|
|
85
|
+
if (!storyObjectExpression) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return storyObjectExpression.properties.some((property) => {
|
|
90
|
+
return property.type === "Property" && readPropertyName(property) === "play";
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isStoryDeclarator(declarator: TSESTree.VariableDeclarator): declarator is StoryCandidateEntry["declarator"] {
|
|
95
|
+
return declarator.id.type === "Identifier" && declarator.init !== null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readTopLevelStoryCandidateEntries(program: AstProgram): StoryCandidateEntry[] {
|
|
99
|
+
const entriesByName = new Map<string, StoryCandidateEntry>();
|
|
100
|
+
|
|
101
|
+
function upsertEntry(
|
|
102
|
+
variableDeclaration: TSESTree.VariableDeclaration,
|
|
103
|
+
declarator: TSESTree.VariableDeclarator,
|
|
104
|
+
): StoryCandidateEntry | null {
|
|
105
|
+
if (!isStoryDeclarator(declarator)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const existingEntry = entriesByName.get(declarator.id.name);
|
|
110
|
+
if (existingEntry) {
|
|
111
|
+
return existingEntry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const entry: StoryCandidateEntry = {
|
|
115
|
+
name: declarator.id.name,
|
|
116
|
+
declaration: variableDeclaration,
|
|
117
|
+
declarator,
|
|
118
|
+
exports: [],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
entriesByName.set(entry.name, entry);
|
|
122
|
+
return entry;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function recordVariableDeclaration(variableDeclaration: TSESTree.VariableDeclaration, isDirectExport: boolean): void {
|
|
126
|
+
variableDeclaration.declarations.forEach((declarator) => {
|
|
127
|
+
const entry = upsertEntry(variableDeclaration, declarator);
|
|
128
|
+
if (!entry || !isDirectExport) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
entry.exports.push({
|
|
133
|
+
kind: "declaration",
|
|
134
|
+
localName: entry.name,
|
|
135
|
+
exportedName: entry.name,
|
|
136
|
+
node: declarator,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
program.body.forEach((statement) => {
|
|
142
|
+
if (statement.type === "VariableDeclaration") {
|
|
143
|
+
recordVariableDeclaration(statement, false);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
statement.type === "ExportNamedDeclaration" &&
|
|
149
|
+
statement.exportKind !== "type" &&
|
|
150
|
+
statement.declaration?.type === "VariableDeclaration"
|
|
151
|
+
) {
|
|
152
|
+
recordVariableDeclaration(statement.declaration, true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
program.body.forEach((statement) => {
|
|
157
|
+
if (
|
|
158
|
+
statement.type !== "ExportNamedDeclaration" ||
|
|
159
|
+
statement.declaration ||
|
|
160
|
+
statement.source ||
|
|
161
|
+
statement.exportKind === "type"
|
|
162
|
+
) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
statement.specifiers.forEach((specifier) => {
|
|
167
|
+
if (
|
|
168
|
+
specifier.type !== "ExportSpecifier" ||
|
|
169
|
+
specifier.exportKind === "type" ||
|
|
170
|
+
specifier.local.type !== "Identifier" ||
|
|
171
|
+
specifier.exported.type !== "Identifier"
|
|
172
|
+
) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const entry = entriesByName.get(specifier.local.name);
|
|
177
|
+
if (!entry) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
entry.exports.push({
|
|
182
|
+
kind: "specifier",
|
|
183
|
+
localName: specifier.local.name,
|
|
184
|
+
exportedName: specifier.exported.name,
|
|
185
|
+
node: specifier,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return [...entriesByName.values()];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isStoryCandidateEntry(entry: StoryCandidateEntry, metaBindingName: string | null): boolean {
|
|
194
|
+
if (!entry.declarator.init || entry.name === metaBindingName) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!readStoryObjectExpression(entry.declarator.init)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const hasStoryTypeContract =
|
|
203
|
+
isStoryTypeReference(entry.declarator.id.typeAnnotation?.typeAnnotation) ||
|
|
204
|
+
isStoryTypeReference(readStoryAssertionTypeAnnotation(entry.declarator.init));
|
|
205
|
+
|
|
206
|
+
return entry.exports.length > 0 || hasStoryTypeContract;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const storyExportContractRule: RuleModule = {
|
|
210
|
+
meta: {
|
|
211
|
+
type: "problem" as const,
|
|
212
|
+
docs: {
|
|
213
|
+
description:
|
|
214
|
+
"Require Storybook story exports to use `const StoryName: Story = { ... }` bindings, enforce the single-vs-multiple export contract, and require a `play` property on every exported story",
|
|
215
|
+
},
|
|
216
|
+
schema: [],
|
|
217
|
+
messages: {
|
|
218
|
+
missingStoryExport:
|
|
219
|
+
"Add at least one exported story object after the default meta. Component story files must export Storybook stories for the sibling component.",
|
|
220
|
+
unexportedStoryBinding:
|
|
221
|
+
"Export this story binding. Story objects in `*.stories.tsx` files must be exported so Storybook can load them.",
|
|
222
|
+
missingStoryTypeAnnotation:
|
|
223
|
+
"Annotate this story binding as `: Story`. Story objects must use a type annotation instead of inference.",
|
|
224
|
+
unexpectedStoryTypeAssertion:
|
|
225
|
+
"Replace this story object assertion with a const type annotation: `const StoryName: Story = { ... };`.",
|
|
226
|
+
missingStoryPlay:
|
|
227
|
+
"Add a `play` property to this story object. Component stories are the required interaction-test surface for the sibling component.",
|
|
228
|
+
invalidSingleStoryExportShape:
|
|
229
|
+
"Use the single-story export shape exactly: `const Default: Story = { ... }; export { Default as {{ componentName }} };`.",
|
|
230
|
+
invalidMultiStoryExportShape:
|
|
231
|
+
"When a story file exports multiple stories, export each one directly as `export const StoryName: Story = { ... };`. Do not re-export local story bindings through an export list.",
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
create(context) {
|
|
235
|
+
return {
|
|
236
|
+
Program(node) {
|
|
237
|
+
const defaultExportDeclaration = readDefaultExportDeclaration(node);
|
|
238
|
+
const metaBindingName =
|
|
239
|
+
defaultExportDeclaration?.declaration.type === "Identifier"
|
|
240
|
+
? defaultExportDeclaration.declaration.name
|
|
241
|
+
: null;
|
|
242
|
+
const storyEntries = readTopLevelStoryCandidateEntries(node).filter((entry) => {
|
|
243
|
+
return isStoryCandidateEntry(entry, metaBindingName);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (storyEntries.length === 0) {
|
|
247
|
+
context.report({
|
|
248
|
+
node,
|
|
249
|
+
messageId: "missingStoryExport",
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
storyEntries.forEach((storyEntry) => {
|
|
255
|
+
if (storyEntry.exports.length === 0) {
|
|
256
|
+
context.report({
|
|
257
|
+
node: storyEntry.declarator.id,
|
|
258
|
+
messageId: "unexportedStoryBinding",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const storyAssertionTypeAnnotation = readStoryAssertionTypeAnnotation(storyEntry.declarator.init);
|
|
263
|
+
if (storyAssertionTypeAnnotation && isStoryTypeReference(storyAssertionTypeAnnotation)) {
|
|
264
|
+
context.report({
|
|
265
|
+
node: storyEntry.declarator.init,
|
|
266
|
+
messageId: "unexpectedStoryTypeAssertion",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!isStoryTypeReference(storyEntry.declarator.id.typeAnnotation?.typeAnnotation)) {
|
|
271
|
+
context.report({
|
|
272
|
+
node: storyEntry.declarator.id,
|
|
273
|
+
messageId: "missingStoryTypeAnnotation",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!hasPlayProperty(storyEntry.declarator.init)) {
|
|
278
|
+
context.report({
|
|
279
|
+
node: readStoryObjectExpression(storyEntry.declarator.init) ?? storyEntry.declarator,
|
|
280
|
+
messageId: "missingStoryPlay",
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const exportedStoryEntries = storyEntries.filter((storyEntry) => storyEntry.exports.length > 0);
|
|
286
|
+
if (exportedStoryEntries.length === 0) {
|
|
287
|
+
context.report({
|
|
288
|
+
node,
|
|
289
|
+
messageId: "missingStoryExport",
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (exportedStoryEntries.length === 1) {
|
|
295
|
+
const singleStoryEntry = exportedStoryEntries[0];
|
|
296
|
+
if (!singleStoryEntry) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const expectedComponentName = readExpectedComponentNameFromStoryFilename(context.filename) ?? "ComponentName";
|
|
301
|
+
const firstExport = singleStoryEntry.exports[0];
|
|
302
|
+
const hasValidSingleStoryExportShape =
|
|
303
|
+
singleStoryEntry.name === "Default" &&
|
|
304
|
+
singleStoryEntry.exports.length === 1 &&
|
|
305
|
+
firstExport?.kind === "specifier" &&
|
|
306
|
+
firstExport.localName === "Default" &&
|
|
307
|
+
firstExport.exportedName === expectedComponentName;
|
|
308
|
+
|
|
309
|
+
if (!hasValidSingleStoryExportShape) {
|
|
310
|
+
context.report({
|
|
311
|
+
node: firstExport?.node ?? singleStoryEntry.declarator,
|
|
312
|
+
messageId: "invalidSingleStoryExportShape",
|
|
313
|
+
data: {
|
|
314
|
+
componentName: expectedComponentName,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
exportedStoryEntries.forEach((storyEntry) => {
|
|
323
|
+
const firstExport = storyEntry.exports[0];
|
|
324
|
+
const hasValidMultiStoryExportShape =
|
|
325
|
+
storyEntry.exports.length === 1 &&
|
|
326
|
+
firstExport?.kind === "declaration" &&
|
|
327
|
+
firstExport.exportedName === storyEntry.name;
|
|
328
|
+
|
|
329
|
+
if (hasValidMultiStoryExportShape) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
context.report({
|
|
334
|
+
node: firstExport?.node ?? storyEntry.declarator,
|
|
335
|
+
messageId: "invalidMultiStoryExportShape",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export default storyExportContractRule;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { RuleModule } from "./types.ts";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getStorySourceBaseName, readPathFromStoriesDirectory, readRootPathBeforeDirectory } from "./helpers.ts";
|
|
5
|
+
|
|
6
|
+
function readRequiredSiblingComponentFilePath(filename: string): string | null {
|
|
7
|
+
const storySourceBaseName = getStorySourceBaseName(filename);
|
|
8
|
+
if (!storySourceBaseName) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const siblingDirectoryPath = readRootPathBeforeDirectory(filename, "stories");
|
|
13
|
+
if (siblingDirectoryPath === null) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return join(siblingDirectoryPath, `${storySourceBaseName}.tsx`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const storyFileLocationConventionRule: RuleModule = {
|
|
21
|
+
meta: {
|
|
22
|
+
type: "problem" as const,
|
|
23
|
+
docs: {
|
|
24
|
+
description:
|
|
25
|
+
'Require Storybook files to live somewhere under a sibling "stories/" directory and match a sibling component ownership file basename',
|
|
26
|
+
},
|
|
27
|
+
schema: [],
|
|
28
|
+
messages: {
|
|
29
|
+
invalidStoryFileLocation:
|
|
30
|
+
'Move this story file under a "stories/" directory. Storybook files must not live outside a sibling "stories/" tree.',
|
|
31
|
+
missingSiblingComponent:
|
|
32
|
+
'Rename or move this story so it matches an existing sibling component ownership file. Expected "{{ requiredComponentFilePath }}" to exist for this story file.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
create(context) {
|
|
36
|
+
return {
|
|
37
|
+
Program(node) {
|
|
38
|
+
const relativeStoryPath = readPathFromStoriesDirectory(context.filename);
|
|
39
|
+
if (relativeStoryPath === null) {
|
|
40
|
+
context.report({
|
|
41
|
+
node,
|
|
42
|
+
messageId: "invalidStoryFileLocation",
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const requiredComponentFilePath = readRequiredSiblingComponentFilePath(context.filename);
|
|
48
|
+
if (requiredComponentFilePath === null || existsSync(requiredComponentFilePath)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
messageId: "missingSiblingComponent",
|
|
55
|
+
data: {
|
|
56
|
+
requiredComponentFilePath,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default storyFileLocationConventionRule;
|