@idealyst/tooling 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * File type classification based on file extension patterns
3
+ */
4
+ export type FileType =
5
+ | 'shared' // .tsx, .jsx - shared cross-platform files
6
+ | 'web' // .web.tsx, .web.jsx - web-specific files
7
+ | 'native' // .native.tsx, .native.jsx - React Native-specific files
8
+ | 'styles' // .styles.tsx, .styles.ts - style definition files
9
+ | 'types' // .types.ts, types.ts - type definition files
10
+ | 'other'; // non-component files
11
+
12
+ /**
13
+ * Platform classification for imports
14
+ */
15
+ export type Platform = 'react-native' | 'react-dom' | 'neutral';
16
+
17
+ /**
18
+ * Violation type describing what kind of platform mismatch occurred
19
+ */
20
+ export type ViolationType =
21
+ | 'native-in-shared' // React Native primitive used in shared file
22
+ | 'dom-in-shared' // React DOM primitive used in shared file
23
+ | 'native-in-web' // React Native primitive used in web file
24
+ | 'dom-in-native'; // React DOM primitive used in native file
25
+
26
+ /**
27
+ * Severity level for violations
28
+ */
29
+ export type Severity = 'error' | 'warning' | 'info';
30
+
31
+ /**
32
+ * Information about a single import
33
+ */
34
+ export interface ImportInfo {
35
+ /** The imported identifier name */
36
+ name: string;
37
+ /** Original name if aliased (e.g., `Image as RNImage`) */
38
+ originalName?: string;
39
+ /** The module source (e.g., 'react-native', 'react-dom') */
40
+ source: string;
41
+ /** The platform this import belongs to */
42
+ platform: Platform;
43
+ /** Line number in source file */
44
+ line: number;
45
+ /** Column number in source file */
46
+ column: number;
47
+ /** Whether this is a default import */
48
+ isDefault: boolean;
49
+ /** Whether this is a namespace import (import * as X) */
50
+ isNamespace: boolean;
51
+ /** Whether this is a type-only import */
52
+ isTypeOnly: boolean;
53
+ }
54
+
55
+ /**
56
+ * A single violation found during analysis
57
+ */
58
+ export interface Violation {
59
+ /** Type of violation */
60
+ type: ViolationType;
61
+ /** The primitive/component that caused the violation */
62
+ primitive: string;
63
+ /** The module source (e.g., 'react-native', 'react-dom') */
64
+ source: string;
65
+ /** Path to the file with the violation */
66
+ filePath: string;
67
+ /** Line number where violation occurred */
68
+ line: number;
69
+ /** Column number where violation occurred */
70
+ column: number;
71
+ /** Human-readable message describing the violation */
72
+ message: string;
73
+ /** Severity level of this violation */
74
+ severity: Severity;
75
+ }
76
+
77
+ /**
78
+ * Result of analyzing a single file
79
+ */
80
+ export interface AnalysisResult {
81
+ /** Path to the analyzed file */
82
+ filePath: string;
83
+ /** Classified type of the file */
84
+ fileType: FileType;
85
+ /** List of violations found */
86
+ violations: Violation[];
87
+ /** All imports found in the file */
88
+ imports: ImportInfo[];
89
+ /** Whether the file passed validation (no violations) */
90
+ passed: boolean;
91
+ }
92
+
93
+ /**
94
+ * Options for configuring the platform import analyzer
95
+ */
96
+ export interface AnalyzerOptions {
97
+ /**
98
+ * Default severity level for violations
99
+ * @default 'error'
100
+ */
101
+ severity?: Severity;
102
+
103
+ /**
104
+ * Additional React Native primitives to flag beyond the built-in list
105
+ * Useful for flagging custom native-only components
106
+ */
107
+ additionalNativePrimitives?: string[];
108
+
109
+ /**
110
+ * Additional React DOM primitives to flag beyond the built-in list
111
+ * Useful for flagging custom web-only components
112
+ */
113
+ additionalDomPrimitives?: string[];
114
+
115
+ /**
116
+ * Primitives to ignore/allow even if they would normally be flagged
117
+ */
118
+ ignoredPrimitives?: string[];
119
+
120
+ /**
121
+ * Glob patterns for files to skip analysis on
122
+ * @example ['**\/*.test.tsx', '**\/*.stories.tsx']
123
+ */
124
+ ignoredPatterns?: string[];
125
+
126
+ /**
127
+ * Additional module sources to treat as React Native
128
+ * @example ['react-native-gesture-handler', 'react-native-reanimated']
129
+ */
130
+ additionalNativeSources?: string[];
131
+
132
+ /**
133
+ * Additional module sources to treat as React DOM
134
+ * @example ['react-dom/client']
135
+ */
136
+ additionalDomSources?: string[];
137
+ }
138
+
139
+ /**
140
+ * Input for batch file analysis
141
+ */
142
+ export interface FileInput {
143
+ /** File path */
144
+ path: string;
145
+ /** File content (source code) */
146
+ content: string;
147
+ }
148
+
149
+ /**
150
+ * Primitive rule definition
151
+ */
152
+ export interface PrimitiveRule {
153
+ /** Name of the primitive/component */
154
+ name: string;
155
+ /** Module source it comes from */
156
+ source: string;
157
+ /** Platform it belongs to */
158
+ platform: Platform;
159
+ /** Optional description of why it's platform-specific */
160
+ description?: string;
161
+ }
162
+
163
+ /**
164
+ * Rule set containing primitives for a specific platform
165
+ */
166
+ export interface PrimitiveRuleSet {
167
+ /** Platform these rules apply to */
168
+ platform: Platform;
169
+ /** List of primitive rules */
170
+ primitives: PrimitiveRule[];
171
+ /** Module sources that indicate this platform */
172
+ sources: string[];
173
+ }
@@ -0,0 +1,135 @@
1
+ import { FileType } from '../types';
2
+ import * as path from 'path';
3
+
4
+ /**
5
+ * Extension patterns for classification
6
+ * Order matters - more specific patterns should come first
7
+ */
8
+ const EXTENSION_PATTERNS: Array<{ pattern: RegExp; type: FileType }> = [
9
+ // Platform-specific component files
10
+ { pattern: /\.web\.(tsx|jsx)$/, type: 'web' },
11
+ { pattern: /\.native\.(tsx|jsx)$/, type: 'native' },
12
+ { pattern: /\.ios\.(tsx|jsx)$/, type: 'native' },
13
+ { pattern: /\.android\.(tsx|jsx)$/, type: 'native' },
14
+
15
+ // Style files (can be .ts or .tsx)
16
+ { pattern: /\.styles?\.(tsx?|jsx?)$/, type: 'styles' },
17
+
18
+ // Type definition files
19
+ { pattern: /\.types?\.(ts|tsx)$/, type: 'types' },
20
+ { pattern: /types\.(ts|tsx)$/, type: 'types' },
21
+ { pattern: /\.d\.ts$/, type: 'types' },
22
+
23
+ // Shared component files (generic .tsx/.jsx without platform suffix)
24
+ { pattern: /\.(tsx|jsx)$/, type: 'shared' },
25
+ ];
26
+
27
+ /**
28
+ * Files that should be classified as 'other' regardless of extension
29
+ */
30
+ const EXCLUDED_PATTERNS: RegExp[] = [
31
+ /\.test\.(tsx?|jsx?)$/,
32
+ /\.spec\.(tsx?|jsx?)$/,
33
+ /\.stories\.(tsx?|jsx?)$/,
34
+ /\.config\.(ts|js)$/,
35
+ /index\.(ts|tsx|js|jsx)$/,
36
+ ];
37
+
38
+ /**
39
+ * Classifies a file based on its path and extension
40
+ *
41
+ * @param filePath - The file path to classify
42
+ * @returns The file type classification
43
+ *
44
+ * @example
45
+ * classifyFile('Button.tsx') // 'shared'
46
+ * classifyFile('Button.web.tsx') // 'web'
47
+ * classifyFile('Button.native.tsx') // 'native'
48
+ * classifyFile('Button.styles.tsx') // 'styles'
49
+ * classifyFile('types.ts') // 'types'
50
+ */
51
+ export function classifyFile(filePath: string): FileType {
52
+ const fileName = path.basename(filePath);
53
+
54
+ // Check if this file should be excluded from component analysis
55
+ for (const pattern of EXCLUDED_PATTERNS) {
56
+ if (pattern.test(fileName)) {
57
+ return 'other';
58
+ }
59
+ }
60
+
61
+ // Match against extension patterns
62
+ for (const { pattern, type } of EXTENSION_PATTERNS) {
63
+ if (pattern.test(fileName)) {
64
+ return type;
65
+ }
66
+ }
67
+
68
+ return 'other';
69
+ }
70
+
71
+ /**
72
+ * Checks if a file is a component file that should be analyzed
73
+ *
74
+ * @param filePath - The file path to check
75
+ * @returns True if the file is a component file (.tsx or .jsx)
76
+ */
77
+ export function isComponentFile(filePath: string): boolean {
78
+ const fileType = classifyFile(filePath);
79
+ return fileType === 'shared' || fileType === 'web' || fileType === 'native';
80
+ }
81
+
82
+ /**
83
+ * Checks if a file is a shared (cross-platform) component file
84
+ * These are the files that should NOT contain platform-specific imports
85
+ *
86
+ * @param filePath - The file path to check
87
+ * @returns True if the file is a shared component file
88
+ */
89
+ export function isSharedFile(filePath: string): boolean {
90
+ return classifyFile(filePath) === 'shared';
91
+ }
92
+
93
+ /**
94
+ * Checks if a file is platform-specific
95
+ *
96
+ * @param filePath - The file path to check
97
+ * @returns True if the file is web or native specific
98
+ */
99
+ export function isPlatformSpecificFile(filePath: string): boolean {
100
+ const fileType = classifyFile(filePath);
101
+ return fileType === 'web' || fileType === 'native';
102
+ }
103
+
104
+ /**
105
+ * Gets the expected platform for a file
106
+ *
107
+ * @param filePath - The file path to check
108
+ * @returns The expected platform, or null for shared/other files
109
+ */
110
+ export function getExpectedPlatform(filePath: string): 'web' | 'native' | null {
111
+ const fileType = classifyFile(filePath);
112
+ if (fileType === 'web') return 'web';
113
+ if (fileType === 'native') return 'native';
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Extracts the base component name from a file path
119
+ *
120
+ * @param filePath - The file path
121
+ * @returns The base component name without platform suffix or extension
122
+ *
123
+ * @example
124
+ * getBaseName('Button.web.tsx') // 'Button'
125
+ * getBaseName('Button.native.tsx') // 'Button'
126
+ * getBaseName('Button.tsx') // 'Button'
127
+ */
128
+ export function getBaseName(filePath: string): string {
129
+ const fileName = path.basename(filePath);
130
+ return fileName
131
+ .replace(/\.(web|native|ios|android)\.(tsx|jsx|ts|js)$/, '')
132
+ .replace(/\.styles?\.(tsx|jsx|ts|js)$/, '')
133
+ .replace(/\.types?\.(tsx|ts)$/, '')
134
+ .replace(/\.(tsx|jsx|ts|js)$/, '');
135
+ }
@@ -0,0 +1,235 @@
1
+ import * as ts from 'typescript';
2
+ import { ImportInfo, Platform } from '../types';
3
+ import { REACT_NATIVE_SOURCES, REACT_DOM_SOURCES } from '../rules';
4
+
5
+ /**
6
+ * Options for import parsing
7
+ */
8
+ export interface ImportParserOptions {
9
+ /** Additional sources to treat as React Native */
10
+ additionalNativeSources?: string[];
11
+ /** Additional sources to treat as React DOM */
12
+ additionalDomSources?: string[];
13
+ }
14
+
15
+ /**
16
+ * Determines the platform for a given import source
17
+ */
18
+ export function getPlatformForSource(
19
+ source: string,
20
+ options?: ImportParserOptions
21
+ ): Platform {
22
+ const nativeSources = new Set([
23
+ ...REACT_NATIVE_SOURCES,
24
+ ...(options?.additionalNativeSources ?? []),
25
+ ]);
26
+
27
+ const domSources = new Set([
28
+ ...REACT_DOM_SOURCES,
29
+ ...(options?.additionalDomSources ?? []),
30
+ ]);
31
+
32
+ // Check for exact matches first
33
+ if (nativeSources.has(source)) return 'react-native';
34
+ if (domSources.has(source)) return 'react-dom';
35
+
36
+ // Check for prefix matches (e.g., 'react-native-xxx')
37
+ if (source.startsWith('react-native')) return 'react-native';
38
+ if (source.startsWith('react-dom')) return 'react-dom';
39
+
40
+ return 'neutral';
41
+ }
42
+
43
+ /**
44
+ * Parses import statements from TypeScript/JavaScript source code
45
+ *
46
+ * @param sourceCode - The source code to parse
47
+ * @param filePath - Optional file path for better error messages
48
+ * @param options - Parser options
49
+ * @returns Array of import information
50
+ */
51
+ export function parseImports(
52
+ sourceCode: string,
53
+ filePath: string = 'unknown.tsx',
54
+ options?: ImportParserOptions
55
+ ): ImportInfo[] {
56
+ const imports: ImportInfo[] = [];
57
+
58
+ // Create a source file from the code
59
+ const sourceFile = ts.createSourceFile(
60
+ filePath,
61
+ sourceCode,
62
+ ts.ScriptTarget.Latest,
63
+ true,
64
+ filePath.endsWith('.tsx') || filePath.endsWith('.jsx')
65
+ ? ts.ScriptKind.TSX
66
+ : ts.ScriptKind.TS
67
+ );
68
+
69
+ // Walk the AST to find import declarations
70
+ const visit = (node: ts.Node): void => {
71
+ if (ts.isImportDeclaration(node)) {
72
+ const importInfo = parseImportDeclaration(node, sourceFile, options);
73
+ imports.push(...importInfo);
74
+ }
75
+
76
+ // Also check for require() calls
77
+ if (
78
+ ts.isCallExpression(node) &&
79
+ ts.isIdentifier(node.expression) &&
80
+ node.expression.text === 'require' &&
81
+ node.arguments.length === 1 &&
82
+ ts.isStringLiteral(node.arguments[0])
83
+ ) {
84
+ const source = node.arguments[0].text;
85
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(
86
+ node.getStart()
87
+ );
88
+
89
+ imports.push({
90
+ name: 'require',
91
+ source,
92
+ platform: getPlatformForSource(source, options),
93
+ line: line + 1,
94
+ column: character + 1,
95
+ isDefault: false,
96
+ isNamespace: true,
97
+ isTypeOnly: false,
98
+ });
99
+ }
100
+
101
+ ts.forEachChild(node, visit);
102
+ };
103
+
104
+ visit(sourceFile);
105
+
106
+ return imports;
107
+ }
108
+
109
+ /**
110
+ * Parses a single import declaration into ImportInfo objects
111
+ */
112
+ function parseImportDeclaration(
113
+ node: ts.ImportDeclaration,
114
+ sourceFile: ts.SourceFile,
115
+ options?: ImportParserOptions
116
+ ): ImportInfo[] {
117
+ const imports: ImportInfo[] = [];
118
+
119
+ // Get the module specifier (the source)
120
+ if (!ts.isStringLiteral(node.moduleSpecifier)) {
121
+ return imports;
122
+ }
123
+
124
+ const source = node.moduleSpecifier.text;
125
+ const platform = getPlatformForSource(source, options);
126
+ const isTypeOnly = node.importClause?.isTypeOnly ?? false;
127
+
128
+ const importClause = node.importClause;
129
+ if (!importClause) {
130
+ // Side-effect import: import 'module'
131
+ return imports;
132
+ }
133
+
134
+ // Default import: import X from 'module'
135
+ if (importClause.name) {
136
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(
137
+ importClause.name.getStart()
138
+ );
139
+
140
+ imports.push({
141
+ name: importClause.name.text,
142
+ source,
143
+ platform,
144
+ line: line + 1,
145
+ column: character + 1,
146
+ isDefault: true,
147
+ isNamespace: false,
148
+ isTypeOnly,
149
+ });
150
+ }
151
+
152
+ // Named and namespace imports
153
+ const namedBindings = importClause.namedBindings;
154
+ if (namedBindings) {
155
+ if (ts.isNamespaceImport(namedBindings)) {
156
+ // Namespace import: import * as X from 'module'
157
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(
158
+ namedBindings.name.getStart()
159
+ );
160
+
161
+ imports.push({
162
+ name: namedBindings.name.text,
163
+ source,
164
+ platform,
165
+ line: line + 1,
166
+ column: character + 1,
167
+ isDefault: false,
168
+ isNamespace: true,
169
+ isTypeOnly,
170
+ });
171
+ } else if (ts.isNamedImports(namedBindings)) {
172
+ // Named imports: import { X, Y as Z } from 'module'
173
+ for (const element of namedBindings.elements) {
174
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(
175
+ element.name.getStart()
176
+ );
177
+
178
+ const importedName = element.propertyName?.text ?? element.name.text;
179
+ const localName = element.name.text;
180
+
181
+ imports.push({
182
+ name: localName,
183
+ originalName: element.propertyName ? importedName : undefined,
184
+ source,
185
+ platform,
186
+ line: line + 1,
187
+ column: character + 1,
188
+ isDefault: false,
189
+ isNamespace: false,
190
+ isTypeOnly: isTypeOnly || element.isTypeOnly,
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ return imports;
197
+ }
198
+
199
+ /**
200
+ * Filters imports to only those from platform-specific sources
201
+ */
202
+ export function filterPlatformImports(
203
+ imports: ImportInfo[],
204
+ platform?: Platform
205
+ ): ImportInfo[] {
206
+ return imports.filter((imp) => {
207
+ if (imp.platform === 'neutral') return false;
208
+ if (platform && imp.platform !== platform) return false;
209
+ return true;
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Gets all unique import sources from a list of imports
215
+ */
216
+ export function getUniqueSources(imports: ImportInfo[]): string[] {
217
+ return [...new Set(imports.map((imp) => imp.source))];
218
+ }
219
+
220
+ /**
221
+ * Groups imports by their source module
222
+ */
223
+ export function groupImportsBySource(
224
+ imports: ImportInfo[]
225
+ ): Map<string, ImportInfo[]> {
226
+ const grouped = new Map<string, ImportInfo[]>();
227
+
228
+ for (const imp of imports) {
229
+ const existing = grouped.get(imp.source) ?? [];
230
+ existing.push(imp);
231
+ grouped.set(imp.source, existing);
232
+ }
233
+
234
+ return grouped;
235
+ }
@@ -0,0 +1,2 @@
1
+ export * from './fileClassifier';
2
+ export * from './importParser';