@api-extractor-tools/eslint-plugin 0.1.0-alpha.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 (76) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +183 -0
  4. package/api-extractor.json +10 -0
  5. package/dist/configs/index.d.ts +6 -0
  6. package/dist/configs/index.d.ts.map +1 -0
  7. package/dist/configs/index.js +11 -0
  8. package/dist/configs/index.js.map +1 -0
  9. package/dist/configs/recommended.d.ts +31 -0
  10. package/dist/configs/recommended.d.ts.map +1 -0
  11. package/dist/configs/recommended.js +45 -0
  12. package/dist/configs/recommended.js.map +1 -0
  13. package/dist/index.d.ts +74 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +68 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/rules/index.d.ts +14 -0
  18. package/dist/rules/index.d.ts.map +1 -0
  19. package/dist/rules/index.js +20 -0
  20. package/dist/rules/index.js.map +1 -0
  21. package/dist/rules/missing-release-tag.d.ts +8 -0
  22. package/dist/rules/missing-release-tag.d.ts.map +1 -0
  23. package/dist/rules/missing-release-tag.js +148 -0
  24. package/dist/rules/missing-release-tag.js.map +1 -0
  25. package/dist/rules/override-keyword.d.ts +8 -0
  26. package/dist/rules/override-keyword.d.ts.map +1 -0
  27. package/dist/rules/override-keyword.js +106 -0
  28. package/dist/rules/override-keyword.js.map +1 -0
  29. package/dist/rules/package-documentation.d.ts +8 -0
  30. package/dist/rules/package-documentation.d.ts.map +1 -0
  31. package/dist/rules/package-documentation.js +70 -0
  32. package/dist/rules/package-documentation.js.map +1 -0
  33. package/dist/types.d.ts +90 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +19 -0
  36. package/dist/types.js.map +1 -0
  37. package/dist/utils/config-loader.d.ts +47 -0
  38. package/dist/utils/config-loader.d.ts.map +1 -0
  39. package/dist/utils/config-loader.js +163 -0
  40. package/dist/utils/config-loader.js.map +1 -0
  41. package/dist/utils/entry-point.d.ts +56 -0
  42. package/dist/utils/entry-point.d.ts.map +1 -0
  43. package/dist/utils/entry-point.js +198 -0
  44. package/dist/utils/entry-point.js.map +1 -0
  45. package/dist/utils/index.d.ts +8 -0
  46. package/dist/utils/index.d.ts.map +1 -0
  47. package/dist/utils/index.js +21 -0
  48. package/dist/utils/index.js.map +1 -0
  49. package/dist/utils/tsdoc-parser.d.ts +58 -0
  50. package/dist/utils/tsdoc-parser.d.ts.map +1 -0
  51. package/dist/utils/tsdoc-parser.js +137 -0
  52. package/dist/utils/tsdoc-parser.js.map +1 -0
  53. package/package.json +44 -0
  54. package/src/configs/index.ts +6 -0
  55. package/src/configs/recommended.ts +46 -0
  56. package/src/index.ts +111 -0
  57. package/src/rules/index.ts +18 -0
  58. package/src/rules/missing-release-tag.ts +203 -0
  59. package/src/rules/override-keyword.ts +139 -0
  60. package/src/rules/package-documentation.ts +90 -0
  61. package/src/types.ts +104 -0
  62. package/src/utils/config-loader.ts +194 -0
  63. package/src/utils/entry-point.ts +247 -0
  64. package/src/utils/index.ts +17 -0
  65. package/src/utils/tsdoc-parser.ts +163 -0
  66. package/temp/eslint-plugin.api.md +118 -0
  67. package/test/index.test.ts +66 -0
  68. package/test/rules/missing-release-tag.test.ts +184 -0
  69. package/test/rules/override-keyword.test.ts +171 -0
  70. package/test/rules/package-documentation.test.ts +152 -0
  71. package/test/tsconfig.json +11 -0
  72. package/test/utils/config-loader.test.ts +199 -0
  73. package/test/utils/entry-point.test.ts +172 -0
  74. package/test/utils/tsdoc-parser.test.ts +113 -0
  75. package/tsconfig.json +12 -0
  76. package/vitest.config.mts +25 -0
package/src/types.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Types for the API Extractor ESLint plugin.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ /**
8
+ * Log levels supported by API Extractor message configuration.
9
+ * @public
10
+ */
11
+ export type ApiExtractorLogLevel = 'error' | 'warning' | 'none'
12
+
13
+ /**
14
+ * Configuration for a single message type in API Extractor.
15
+ * @public
16
+ */
17
+ export interface MessageConfig {
18
+ logLevel: ApiExtractorLogLevel
19
+ addToApiReportFile?: boolean
20
+ }
21
+
22
+ /**
23
+ * The messages configuration section from api-extractor.json.
24
+ * @public
25
+ */
26
+ export interface ApiExtractorMessagesConfig {
27
+ compilerMessageReporting?: {
28
+ default?: MessageConfig
29
+ [messageId: string]: MessageConfig | undefined
30
+ }
31
+ extractorMessageReporting?: {
32
+ default?: MessageConfig
33
+ 'ae-missing-release-tag'?: MessageConfig
34
+ 'ae-forgotten-export'?: MessageConfig
35
+ 'ae-internal-missing-underscore'?: MessageConfig
36
+ 'ae-incompatible-release-tags'?: MessageConfig
37
+ [messageId: string]: MessageConfig | undefined
38
+ }
39
+ tsdocMessageReporting?: {
40
+ default?: MessageConfig
41
+ [messageId: string]: MessageConfig | undefined
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Partial representation of api-extractor.json relevant for this plugin.
47
+ * @public
48
+ */
49
+ export interface ApiExtractorConfig {
50
+ extends?: string
51
+ mainEntryPointFilePath?: string
52
+ messages?: ApiExtractorMessagesConfig
53
+ }
54
+
55
+ /**
56
+ * Release tags recognized by API Extractor.
57
+ * @public
58
+ */
59
+ export type ReleaseTag = 'public' | 'beta' | 'alpha' | 'internal'
60
+
61
+ /**
62
+ * All valid release tags.
63
+ * @public
64
+ */
65
+ export const RELEASE_TAGS: readonly ReleaseTag[] = [
66
+ 'public',
67
+ 'beta',
68
+ 'alpha',
69
+ 'internal',
70
+ ] as const
71
+
72
+ /**
73
+ * Options for the missing-release-tag rule.
74
+ * @public
75
+ */
76
+ export interface MissingReleaseTagRuleOptions {
77
+ configPath?: string
78
+ }
79
+
80
+ /**
81
+ * Options for the override-keyword rule.
82
+ * @public
83
+ */
84
+ export interface OverrideKeywordRuleOptions {
85
+ configPath?: string
86
+ }
87
+
88
+ /**
89
+ * Options for the package-documentation rule.
90
+ * @public
91
+ */
92
+ export interface PackageDocumentationRuleOptions {
93
+ configPath?: string
94
+ }
95
+
96
+ /**
97
+ * Resolved entry points from package.json.
98
+ * @public
99
+ */
100
+ export interface ResolvedEntryPoints {
101
+ main?: string
102
+ types?: string
103
+ exports: string[]
104
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Utilities for discovering and loading API Extractor configuration.
3
+ * @internal
4
+ */
5
+
6
+ import * as fs from 'fs'
7
+ import * as path from 'path'
8
+ import type {
9
+ ApiExtractorConfig,
10
+ ApiExtractorLogLevel,
11
+ ApiExtractorMessagesConfig,
12
+ } from '../types'
13
+
14
+ /**
15
+ * Default message configuration when no api-extractor.json is found.
16
+ */
17
+ const DEFAULT_MESSAGES_CONFIG: ApiExtractorMessagesConfig = {
18
+ extractorMessageReporting: {
19
+ default: { logLevel: 'warning' },
20
+ 'ae-missing-release-tag': { logLevel: 'warning' },
21
+ },
22
+ }
23
+
24
+ /**
25
+ * Cache for loaded configurations to avoid repeated file reads.
26
+ */
27
+ const configCache = new Map<string, ApiExtractorConfig | null>()
28
+
29
+ /**
30
+ * Searches upward from the given directory to find api-extractor.json.
31
+ *
32
+ * @param startDir - Directory to start searching from
33
+ * @returns Path to api-extractor.json if found, undefined otherwise
34
+ */
35
+ export function findApiExtractorConfig(startDir: string): string | undefined {
36
+ let currentDir = path.resolve(startDir)
37
+ const root = path.parse(currentDir).root
38
+
39
+ while (currentDir !== root) {
40
+ const configPath = path.join(currentDir, 'api-extractor.json')
41
+ if (fs.existsSync(configPath)) {
42
+ return configPath
43
+ }
44
+ currentDir = path.dirname(currentDir)
45
+ }
46
+
47
+ return undefined
48
+ }
49
+
50
+ /**
51
+ * Loads and parses an api-extractor.json file.
52
+ *
53
+ * @param configPath - Path to the api-extractor.json file
54
+ * @returns Parsed configuration or null if file cannot be read
55
+ */
56
+ export function loadApiExtractorConfig(
57
+ configPath: string,
58
+ ): ApiExtractorConfig | null {
59
+ // Check cache first
60
+ const cached = configCache.get(configPath)
61
+ if (cached !== undefined) {
62
+ return cached
63
+ }
64
+
65
+ try {
66
+ const content = fs.readFileSync(configPath, 'utf-8')
67
+ // Remove comments (api-extractor.json supports JSON with comments)
68
+ const jsonContent = content.replace(/\/\/.*$|\/\*[\s\S]*?\*\//gm, '')
69
+ const config = JSON.parse(jsonContent) as ApiExtractorConfig
70
+
71
+ // Handle extends
72
+ if (config.extends) {
73
+ const baseConfigPath = path.resolve(
74
+ path.dirname(configPath),
75
+ config.extends,
76
+ )
77
+ const baseConfig = loadApiExtractorConfig(baseConfigPath)
78
+ if (baseConfig) {
79
+ // Merge base config with current config
80
+ const merged = mergeConfigs(baseConfig, config)
81
+ configCache.set(configPath, merged)
82
+ return merged
83
+ }
84
+ }
85
+
86
+ configCache.set(configPath, config)
87
+ return config
88
+ } catch {
89
+ configCache.set(configPath, null)
90
+ return null
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Merges two API Extractor configurations, with the override taking precedence.
96
+ */
97
+ function mergeConfigs(
98
+ base: ApiExtractorConfig,
99
+ override: ApiExtractorConfig,
100
+ ): ApiExtractorConfig {
101
+ return {
102
+ ...base,
103
+ ...override,
104
+ messages: {
105
+ compilerMessageReporting: {
106
+ ...base.messages?.compilerMessageReporting,
107
+ ...override.messages?.compilerMessageReporting,
108
+ },
109
+ extractorMessageReporting: {
110
+ ...base.messages?.extractorMessageReporting,
111
+ ...override.messages?.extractorMessageReporting,
112
+ },
113
+ tsdocMessageReporting: {
114
+ ...base.messages?.tsdocMessageReporting,
115
+ ...override.messages?.tsdocMessageReporting,
116
+ },
117
+ },
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Gets the log level for a specific message ID from the configuration.
123
+ *
124
+ * @param config - API Extractor configuration
125
+ * @param messageId - The message ID (e.g., 'ae-missing-release-tag')
126
+ * @returns The configured log level, or 'warning' as default
127
+ */
128
+ export function getMessageLogLevel(
129
+ config: ApiExtractorConfig | null,
130
+ messageId: string,
131
+ ): ApiExtractorLogLevel {
132
+ if (!config?.messages?.extractorMessageReporting) {
133
+ return (
134
+ DEFAULT_MESSAGES_CONFIG.extractorMessageReporting?.default?.logLevel ??
135
+ 'warning'
136
+ )
137
+ }
138
+
139
+ const reporting = config.messages.extractorMessageReporting
140
+ const messageConfig = reporting[messageId]
141
+
142
+ if (messageConfig?.logLevel) {
143
+ return messageConfig.logLevel
144
+ }
145
+
146
+ return reporting.default?.logLevel ?? 'warning'
147
+ }
148
+
149
+ /**
150
+ * Resolves configuration for a file, using auto-discovery or explicit path.
151
+ *
152
+ * @param filePath - Path to the file being linted
153
+ * @param explicitConfigPath - Optional explicit path to api-extractor.json
154
+ * @returns The resolved configuration or null
155
+ */
156
+ export function resolveConfig(
157
+ filePath: string,
158
+ explicitConfigPath?: string,
159
+ ): ApiExtractorConfig | null {
160
+ if (explicitConfigPath) {
161
+ return loadApiExtractorConfig(explicitConfigPath)
162
+ }
163
+
164
+ const discovered = findApiExtractorConfig(path.dirname(filePath))
165
+ if (discovered) {
166
+ return loadApiExtractorConfig(discovered)
167
+ }
168
+
169
+ return null
170
+ }
171
+
172
+ /**
173
+ * Maps API Extractor log level to ESLint severity.
174
+ *
175
+ * @param logLevel - API Extractor log level
176
+ * @returns ESLint severity (0 = off, 1 = warn, 2 = error)
177
+ */
178
+ export function logLevelToSeverity(logLevel: ApiExtractorLogLevel): 0 | 1 | 2 {
179
+ switch (logLevel) {
180
+ case 'error':
181
+ return 2
182
+ case 'warning':
183
+ return 1
184
+ case 'none':
185
+ return 0
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Clears the configuration cache. Useful for testing.
191
+ */
192
+ export function clearConfigCache(): void {
193
+ configCache.clear()
194
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Utilities for resolving package.json entry points.
3
+ * @internal
4
+ */
5
+
6
+ import * as fs from 'fs'
7
+ import * as path from 'path'
8
+ import type { ResolvedEntryPoints } from '../types'
9
+
10
+ /**
11
+ * Represents the relevant fields from package.json.
12
+ */
13
+ interface PackageJson {
14
+ main?: string
15
+ types?: string
16
+ typings?: string
17
+ module?: string
18
+ exports?: PackageExports
19
+ }
20
+
21
+ /**
22
+ * Package.json exports field can be complex.
23
+ */
24
+ type PackageExports =
25
+ | string
26
+ | { [key: string]: PackageExports | string | undefined }
27
+ | undefined
28
+
29
+ /**
30
+ * Cache for package.json lookups.
31
+ */
32
+ const packageJsonCache = new Map<string, PackageJson | null>()
33
+
34
+ /**
35
+ * Finds the nearest package.json by searching upward from a directory.
36
+ *
37
+ * @param startDir - Directory to start searching from
38
+ * @returns Path to package.json if found, undefined otherwise
39
+ */
40
+ export function findPackageJson(startDir: string): string | undefined {
41
+ let currentDir = path.resolve(startDir)
42
+ const root = path.parse(currentDir).root
43
+
44
+ while (currentDir !== root) {
45
+ const pkgPath = path.join(currentDir, 'package.json')
46
+ if (fs.existsSync(pkgPath)) {
47
+ return pkgPath
48
+ }
49
+ currentDir = path.dirname(currentDir)
50
+ }
51
+
52
+ return undefined
53
+ }
54
+
55
+ /**
56
+ * Loads and parses a package.json file.
57
+ *
58
+ * @param pkgPath - Path to the package.json file
59
+ * @returns Parsed package.json or null if file cannot be read
60
+ */
61
+ export function loadPackageJson(pkgPath: string): PackageJson | null {
62
+ const cached = packageJsonCache.get(pkgPath)
63
+ if (cached !== undefined) {
64
+ return cached
65
+ }
66
+
67
+ try {
68
+ const content = fs.readFileSync(pkgPath, 'utf-8')
69
+ const pkg = JSON.parse(content) as PackageJson
70
+ packageJsonCache.set(pkgPath, pkg)
71
+ return pkg
72
+ } catch {
73
+ packageJsonCache.set(pkgPath, null)
74
+ return null
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Extracts all entry point paths from package.json exports field.
80
+ *
81
+ * @param exports - The exports field value
82
+ * @param results - Array to collect results
83
+ */
84
+ function extractExportPaths(exports: PackageExports, results: string[]): void {
85
+ if (typeof exports === 'string') {
86
+ results.push(exports)
87
+ return
88
+ }
89
+
90
+ if (exports && typeof exports === 'object') {
91
+ for (const value of Object.values(exports)) {
92
+ if (value !== undefined) {
93
+ extractExportPaths(value, results)
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Resolves all entry points from a package.json.
101
+ *
102
+ * @param pkgPath - Path to the package.json file
103
+ * @returns Resolved entry points with absolute paths
104
+ */
105
+ export function resolveEntryPoints(pkgPath: string): ResolvedEntryPoints {
106
+ const pkg = loadPackageJson(pkgPath)
107
+ const pkgDir = path.dirname(pkgPath)
108
+ const result: ResolvedEntryPoints = { exports: [] }
109
+
110
+ if (!pkg) {
111
+ return result
112
+ }
113
+
114
+ // Resolve main entry point
115
+ if (pkg.main) {
116
+ result.main = path.resolve(pkgDir, pkg.main)
117
+ }
118
+
119
+ // Resolve types entry point
120
+ if (pkg.types) {
121
+ result.types = path.resolve(pkgDir, pkg.types)
122
+ } else if (pkg.typings) {
123
+ result.types = path.resolve(pkgDir, pkg.typings)
124
+ }
125
+
126
+ // Resolve exports
127
+ if (pkg.exports) {
128
+ const exportPaths: string[] = []
129
+ extractExportPaths(pkg.exports, exportPaths)
130
+ result.exports = exportPaths.map((p) => path.resolve(pkgDir, p))
131
+ }
132
+
133
+ return result
134
+ }
135
+
136
+ /**
137
+ * Checks if a file is a package entry point.
138
+ *
139
+ * @param filePath - Absolute path to the file being checked
140
+ * @param pkgPath - Path to the package.json
141
+ * @returns True if the file is an entry point
142
+ */
143
+ export function isEntryPoint(filePath: string, pkgPath: string): boolean {
144
+ const entryPoints = resolveEntryPoints(pkgPath)
145
+ const absoluteFilePath = path.resolve(filePath)
146
+
147
+ // Check main
148
+ if (
149
+ entryPoints.main &&
150
+ normalizeForComparison(entryPoints.main) ===
151
+ normalizeForComparison(absoluteFilePath)
152
+ ) {
153
+ return true
154
+ }
155
+
156
+ // Check types
157
+ if (
158
+ entryPoints.types &&
159
+ normalizeForComparison(entryPoints.types) ===
160
+ normalizeForComparison(absoluteFilePath)
161
+ ) {
162
+ return true
163
+ }
164
+
165
+ // Check exports
166
+ for (const exportPath of entryPoints.exports) {
167
+ if (
168
+ normalizeForComparison(exportPath) ===
169
+ normalizeForComparison(absoluteFilePath)
170
+ ) {
171
+ return true
172
+ }
173
+ }
174
+
175
+ // Also check if the TypeScript source file corresponds to the entry point
176
+ // e.g., src/index.ts -> dist/index.js
177
+ const sourceEquivalents = getSourceEquivalents(absoluteFilePath)
178
+ for (const sourcePath of sourceEquivalents) {
179
+ if (
180
+ entryPoints.main &&
181
+ normalizeForComparison(entryPoints.main) ===
182
+ normalizeForComparison(sourcePath)
183
+ ) {
184
+ return true
185
+ }
186
+ if (
187
+ entryPoints.types &&
188
+ normalizeForComparison(entryPoints.types) ===
189
+ normalizeForComparison(sourcePath)
190
+ ) {
191
+ return true
192
+ }
193
+ for (const exportPath of entryPoints.exports) {
194
+ if (
195
+ normalizeForComparison(exportPath) ===
196
+ normalizeForComparison(sourcePath)
197
+ ) {
198
+ return true
199
+ }
200
+ }
201
+ }
202
+
203
+ return false
204
+ }
205
+
206
+ /**
207
+ * Normalizes a path for comparison by removing extension variations.
208
+ */
209
+ function normalizeForComparison(filePath: string): string {
210
+ // Remove common extensions and normalize
211
+ return filePath
212
+ .replace(/\.(js|ts|mjs|cjs|mts|cts|d\.ts|d\.mts|d\.cts)$/, '')
213
+ .replace(/\/index$/, '')
214
+ }
215
+
216
+ /**
217
+ * Gets potential source file equivalents for a dist file.
218
+ */
219
+ function getSourceEquivalents(filePath: string): string[] {
220
+ const equivalents: string[] = []
221
+ const dir = path.dirname(filePath)
222
+ const base = path.basename(filePath).replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '')
223
+
224
+ // Try common source directory patterns
225
+ const sourcePatterns = [
226
+ dir.replace('/dist/', '/src/').replace('\\dist\\', '\\src\\'),
227
+ dir.replace('/build/', '/src/').replace('\\build\\', '\\src\\'),
228
+ dir.replace('/lib/', '/src/').replace('\\lib\\', '\\src\\'),
229
+ ]
230
+
231
+ for (const sourceDir of sourcePatterns) {
232
+ if (sourceDir !== dir) {
233
+ equivalents.push(path.join(sourceDir, `${base}.ts`))
234
+ equivalents.push(path.join(sourceDir, `${base}.tsx`))
235
+ equivalents.push(path.join(sourceDir, `${base}.js`))
236
+ }
237
+ }
238
+
239
+ return equivalents
240
+ }
241
+
242
+ /**
243
+ * Clears the package.json cache. Useful for testing.
244
+ */
245
+ export function clearPackageJsonCache(): void {
246
+ packageJsonCache.clear()
247
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Utility module exports.
3
+ * @internal
4
+ */
5
+
6
+ export { resolveConfig, getMessageLogLevel } from './config-loader'
7
+
8
+ export {
9
+ parseTSDocComment,
10
+ extractReleaseTag,
11
+ hasOverrideTag,
12
+ hasPackageDocumentation,
13
+ getLeadingTSDocComment,
14
+ findAllTSDocComments,
15
+ } from './tsdoc-parser'
16
+
17
+ export { findPackageJson, isEntryPoint } from './entry-point'