@eslint-config-snapshot/cli 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eslint-config-snapshot/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,6 +31,6 @@
31
31
  "commander": "^14.0.3",
32
32
  "debug": "^4.4.3",
33
33
  "fast-glob": "^3.3.3",
34
- "@eslint-config-snapshot/api": "1.2.0"
34
+ "@eslint-config-snapshot/api": "1.3.0"
35
35
  }
36
36
  }
@@ -0,0 +1,394 @@
1
+ import fg from 'fast-glob'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ import { formatShortCatalog, type RuleEntry, type RuleObject, type UsageStats } from '../formatters.js'
6
+ import { resolveGroupRuleCatalogs } from '../runtime.js'
7
+ import { type TerminalIO } from '../terminal.js'
8
+ import { writeDiscoveredWorkspacesSummary, writeSkippedWorkspaceSummary } from './skipped-workspaces.js'
9
+ import { prepareSnapshotExecution } from './snapshot-executor.js'
10
+
11
+ export type CatalogFormat = 'json' | 'short'
12
+
13
+ const CATALOG_FILE_SUFFIX = '.catalog.json'
14
+
15
+ type CatalogRow = {
16
+ groupId: string
17
+ availableRules: string[]
18
+ coreRules: string[]
19
+ pluginRulesByPrefix: Record<string, string[]>
20
+ observedRules: string[]
21
+ missingRules: string[]
22
+ observedOffRules: string[]
23
+ observedActiveRules: string[]
24
+ totalStats: UsageStats & { observedOutsideCatalog: number }
25
+ coreStats: UsageStats
26
+ pluginStats: Array<{ pluginId: string } & UsageStats>
27
+ }
28
+
29
+ type CatalogBaselineFile = CatalogRow & { formatVersion: 1 }
30
+
31
+ type CatalogCheckDiff = {
32
+ groupId: string
33
+ availableBefore: number
34
+ availableAfter: number
35
+ introducedAvailable: number
36
+ removedAvailable: number
37
+ inUseBefore: number
38
+ inUseAfter: number
39
+ errorBefore: number
40
+ errorAfter: number
41
+ warnBefore: number
42
+ warnAfter: number
43
+ offBefore: number
44
+ offAfter: number
45
+ activeBefore: number
46
+ activeAfter: number
47
+ inactiveBefore: number
48
+ inactiveAfter: number
49
+ missingBefore: number
50
+ missingAfter: number
51
+ }
52
+
53
+ export async function executeCatalog(
54
+ cwd: string,
55
+ terminal: TerminalIO,
56
+ snapshotDir: string,
57
+ format: CatalogFormat,
58
+ missingOnly: boolean
59
+ ): Promise<number> {
60
+ const rows = await computeCatalogRows(cwd, terminal, snapshotDir, `catalog:${format}`, true)
61
+
62
+ if (format === 'short') {
63
+ terminal.write(formatShortCatalog(rows, missingOnly))
64
+ return 0
65
+ }
66
+
67
+ const output = rows.map((row) => {
68
+ if (!missingOnly) {
69
+ return row
70
+ }
71
+
72
+ return {
73
+ groupId: row.groupId,
74
+ totalStats: row.totalStats,
75
+ coreStats: row.coreStats,
76
+ pluginStats: row.pluginStats,
77
+ missingRules: row.missingRules
78
+ }
79
+ })
80
+
81
+ terminal.write(`${JSON.stringify(output, null, 2)}\n`)
82
+ return rows.length === 0 ? 1 : 0
83
+ }
84
+
85
+ export async function executeCatalogUpdate(cwd: string, terminal: TerminalIO, snapshotDir: string): Promise<number> {
86
+ const rows = await computeCatalogRows(cwd, terminal, snapshotDir, 'catalog:update', false)
87
+ await writeCatalogBaselineFiles(cwd, snapshotDir, rows)
88
+
89
+ const groups = rows.length
90
+ const available = rows.reduce((sum, row) => sum + row.totalStats.totalAvailable, 0)
91
+ const inUse = rows.reduce((sum, row) => sum + row.totalStats.inUse, 0)
92
+ terminal.write(`🧪 Catalog baseline updated: ${groups} groups, ${available} available rules, ${inUse} currently in use.\n`)
93
+ return 0
94
+ }
95
+
96
+ export async function executeCatalogCheck(cwd: string, terminal: TerminalIO, snapshotDir: string): Promise<number> {
97
+ const rows = await computeCatalogRows(cwd, terminal, snapshotDir, 'catalog:check', false)
98
+ const current = new Map(rows.map((row) => [row.groupId, row]))
99
+ const stored = await loadCatalogBaselineFiles(cwd, snapshotDir)
100
+
101
+ if (stored.size === 0) {
102
+ terminal.write('No catalog baseline found yet.\n')
103
+ terminal.write('Run `eslint-config-snapshot catalog-update` or `eslint-config-snapshot --update --experimental-with-catalog`.\n')
104
+ return 1
105
+ }
106
+
107
+ const diffs = compareCatalogBaselines(stored, current)
108
+ if (diffs.length === 0) {
109
+ terminal.write('Great news: no catalog drift detected.\n')
110
+ return 0
111
+ }
112
+
113
+ terminal.write(`⚠️ Heads up: catalog drift detected in ${diffs.length} groups.\n`)
114
+ for (const diff of diffs) {
115
+ terminal.write(
116
+ [
117
+ `group ${diff.groupId}`,
118
+ ` available: ${diff.availableBefore} -> ${diff.availableAfter} (+${diff.introducedAvailable}/-${diff.removedAvailable})`,
119
+ ` in use: ${diff.inUseBefore} -> ${diff.inUseAfter}`,
120
+ ` severity: error ${diff.errorBefore} -> ${diff.errorAfter} | warn ${diff.warnBefore} -> ${diff.warnAfter} | off ${diff.offBefore} -> ${diff.offAfter}`,
121
+ ` active: ${diff.activeBefore} -> ${diff.activeAfter}`,
122
+ ` off: ${diff.inactiveBefore} -> ${diff.inactiveAfter}`,
123
+ ` not used: ${diff.missingBefore} -> ${diff.missingAfter}`
124
+ ].join('\n')
125
+ )
126
+ terminal.write('\n')
127
+ }
128
+ terminal.subtle('Tip: run `eslint-config-snapshot catalog-update` when you intentionally accept catalog changes.\n')
129
+ return 1
130
+ }
131
+
132
+ async function computeCatalogRows(
133
+ cwd: string,
134
+ terminal: TerminalIO,
135
+ snapshotDir: string,
136
+ commandLabel: string,
137
+ printDiscoverySummary: boolean
138
+ ): Promise<CatalogRow[]> {
139
+ const prepared = await prepareSnapshotExecution({
140
+ cwd,
141
+ snapshotDir,
142
+ terminal,
143
+ commandLabel,
144
+ progressMessage: '🔎 Checking current ESLint configuration...\n'
145
+ })
146
+ if (!prepared.ok) {
147
+ throw new Error(`Catalog operation aborted with exit code ${prepared.exitCode}`)
148
+ }
149
+
150
+ const { foundConfig, currentSnapshots, discoveredWorkspaces, skippedWorkspaces } = prepared
151
+ if (!foundConfig && printDiscoverySummary) {
152
+ writeDiscoveredWorkspacesSummary(terminal, discoveredWorkspaces)
153
+ }
154
+ if (printDiscoverySummary) {
155
+ writeSkippedWorkspaceSummary(terminal, cwd, foundConfig?.path, skippedWorkspaces)
156
+ }
157
+
158
+ const catalogs = await resolveGroupRuleCatalogs(cwd)
159
+ return [...currentSnapshots.values()]
160
+ .map((snapshot) => {
161
+ const observedRules = Object.keys(snapshot.rules).sort((a, b) => a.localeCompare(b))
162
+ const catalog = catalogs.get(snapshot.groupId)
163
+ const availableRules = catalog?.allRules ?? []
164
+ const availableRuleSet = new Set(availableRules)
165
+ const missingRules = availableRules.filter((ruleName) => !snapshot.rules[ruleName])
166
+ const observedOffRules = observedRules.filter((ruleName) => isRuleOffOnly(snapshot.rules[ruleName]))
167
+ const observedActiveRules = observedRules.filter((ruleName) => !isRuleOffOnly(snapshot.rules[ruleName]))
168
+ const observedOutsideCatalog = observedRules.filter((ruleName) => !availableRuleSet.has(ruleName)).length
169
+
170
+ const coreRules = catalog?.coreRules ?? []
171
+ const coreStats = buildUsageStats(coreRules, snapshot.rules)
172
+ const pluginRulesByPrefix = catalog?.pluginRulesByPrefix ?? {}
173
+ const pluginStats = Object.entries(pluginRulesByPrefix)
174
+ .sort((a, b) => a[0].localeCompare(b[0]))
175
+ .map(([pluginId, rules]) => ({
176
+ pluginId: pluginId.slice(0, -1),
177
+ ...buildUsageStats(rules, snapshot.rules)
178
+ }))
179
+
180
+ const totalStats = {
181
+ ...buildUsageStats(availableRules, snapshot.rules),
182
+ observedOutsideCatalog
183
+ }
184
+
185
+ return {
186
+ groupId: snapshot.groupId,
187
+ availableRules,
188
+ coreRules,
189
+ pluginRulesByPrefix,
190
+ observedRules,
191
+ missingRules,
192
+ observedOffRules,
193
+ observedActiveRules,
194
+ totalStats,
195
+ coreStats,
196
+ pluginStats
197
+ }
198
+ })
199
+ .sort((a, b) => a.groupId.localeCompare(b.groupId))
200
+ }
201
+
202
+ async function writeCatalogBaselineFiles(cwd: string, snapshotDir: string, rows: CatalogRow[]): Promise<void> {
203
+ await mkdir(path.join(cwd, snapshotDir), { recursive: true })
204
+ for (const row of rows) {
205
+ const filePath = path.join(cwd, snapshotDir, `${row.groupId}${CATALOG_FILE_SUFFIX}`)
206
+ await mkdir(path.dirname(filePath), { recursive: true })
207
+ const payload: CatalogBaselineFile = {
208
+ formatVersion: 1,
209
+ ...row
210
+ }
211
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
212
+ }
213
+ }
214
+
215
+ async function loadCatalogBaselineFiles(cwd: string, snapshotDir: string): Promise<Map<string, CatalogBaselineFile>> {
216
+ const dir = path.join(cwd, snapshotDir)
217
+ const rawFiles = await fg(`**/*${CATALOG_FILE_SUFFIX}`, {
218
+ cwd: dir,
219
+ absolute: true,
220
+ onlyFiles: true,
221
+ dot: true,
222
+ suppressErrors: true
223
+ })
224
+
225
+ const map = new Map<string, CatalogBaselineFile>()
226
+ const sortedFiles = rawFiles.map(String).sort((a, b) => a.localeCompare(b))
227
+ for (const filePath of sortedFiles) {
228
+ const raw = await readFile(filePath, 'utf8')
229
+ const parsed = JSON.parse(raw) as CatalogBaselineFile
230
+ map.set(parsed.groupId, parsed)
231
+ }
232
+ return map
233
+ }
234
+
235
+ function compareCatalogBaselines(
236
+ before: Map<string, CatalogBaselineFile>,
237
+ after: Map<string, CatalogRow>
238
+ ): CatalogCheckDiff[] {
239
+ const ids = [...new Set([...before.keys(), ...after.keys()])].sort((a, b) => a.localeCompare(b))
240
+ const diffs: CatalogCheckDiff[] = []
241
+
242
+ for (const id of ids) {
243
+ const prev = before.get(id)
244
+ const next = after.get(id)
245
+ if (!prev || !next) {
246
+ diffs.push({
247
+ groupId: id,
248
+ availableBefore: prev?.totalStats.totalAvailable ?? 0,
249
+ availableAfter: next?.totalStats.totalAvailable ?? 0,
250
+ introducedAvailable: next ? diffSet(next.availableRules, prev?.availableRules ?? []).length : 0,
251
+ removedAvailable: prev ? diffSet(prev.availableRules, next?.availableRules ?? []).length : 0,
252
+ inUseBefore: prev?.totalStats.inUse ?? 0,
253
+ inUseAfter: next?.totalStats.inUse ?? 0,
254
+ errorBefore: prev?.totalStats.error ?? 0,
255
+ errorAfter: next?.totalStats.error ?? 0,
256
+ warnBefore: prev?.totalStats.warn ?? 0,
257
+ warnAfter: next?.totalStats.warn ?? 0,
258
+ offBefore: prev?.totalStats.off ?? 0,
259
+ offAfter: next?.totalStats.off ?? 0,
260
+ activeBefore: prev?.totalStats.active ?? 0,
261
+ activeAfter: next?.totalStats.active ?? 0,
262
+ inactiveBefore: prev?.totalStats.inactive ?? 0,
263
+ inactiveAfter: next?.totalStats.inactive ?? 0,
264
+ missingBefore: prev?.totalStats.missing ?? 0,
265
+ missingAfter: next?.totalStats.missing ?? 0
266
+ })
267
+ continue
268
+ }
269
+
270
+ const introduced = diffSet(next.availableRules, prev.availableRules).length
271
+ const removed = diffSet(prev.availableRules, next.availableRules).length
272
+ const changed =
273
+ introduced > 0 ||
274
+ removed > 0 ||
275
+ prev.totalStats.inUse !== next.totalStats.inUse ||
276
+ prev.totalStats.error !== next.totalStats.error ||
277
+ prev.totalStats.warn !== next.totalStats.warn ||
278
+ prev.totalStats.off !== next.totalStats.off ||
279
+ prev.totalStats.active !== next.totalStats.active ||
280
+ prev.totalStats.inactive !== next.totalStats.inactive ||
281
+ prev.totalStats.missing !== next.totalStats.missing
282
+ if (!changed) {
283
+ continue
284
+ }
285
+
286
+ diffs.push({
287
+ groupId: id,
288
+ availableBefore: prev.totalStats.totalAvailable,
289
+ availableAfter: next.totalStats.totalAvailable,
290
+ introducedAvailable: introduced,
291
+ removedAvailable: removed,
292
+ inUseBefore: prev.totalStats.inUse,
293
+ inUseAfter: next.totalStats.inUse,
294
+ errorBefore: prev.totalStats.error,
295
+ errorAfter: next.totalStats.error,
296
+ warnBefore: prev.totalStats.warn,
297
+ warnAfter: next.totalStats.warn,
298
+ offBefore: prev.totalStats.off,
299
+ offAfter: next.totalStats.off,
300
+ activeBefore: prev.totalStats.active,
301
+ activeAfter: next.totalStats.active,
302
+ inactiveBefore: prev.totalStats.inactive,
303
+ inactiveAfter: next.totalStats.inactive,
304
+ missingBefore: prev.totalStats.missing,
305
+ missingAfter: next.totalStats.missing
306
+ })
307
+ }
308
+
309
+ return diffs
310
+ }
311
+
312
+ function diffSet(source: string[], target: string[]): string[] {
313
+ const targetSet = new Set(target)
314
+ return source.filter((item) => !targetSet.has(item))
315
+ }
316
+
317
+ function buildUsageStats(availableRules: string[], observedRules: RuleObject): UsageStats {
318
+ let inUse = 0
319
+ let active = 0
320
+ let inactive = 0
321
+ let error = 0
322
+ let warn = 0
323
+ let off = 0
324
+
325
+ for (const ruleName of availableRules) {
326
+ const observed = observedRules[ruleName]
327
+ if (!observed) {
328
+ continue
329
+ }
330
+ inUse += 1
331
+ const severity = getPrimarySeverity(observed)
332
+ if (severity === 'error') {
333
+ error += 1
334
+ active += 1
335
+ continue
336
+ }
337
+ if (severity === 'warn') {
338
+ warn += 1
339
+ active += 1
340
+ continue
341
+ }
342
+ off += 1
343
+ inactive += 1
344
+ }
345
+
346
+ const totalAvailable = availableRules.length
347
+ const missing = Math.max(0, totalAvailable - inUse)
348
+ return {
349
+ totalAvailable,
350
+ inUse,
351
+ active,
352
+ inactive,
353
+ error,
354
+ warn,
355
+ off,
356
+ missing,
357
+ inUsePct: toPercent(inUse, totalAvailable),
358
+ activePctOfInUse: toPercent(active, inUse)
359
+ }
360
+ }
361
+
362
+ function isRuleOffOnly(entry: RuleObject[string] | undefined): boolean {
363
+ if (!entry) {
364
+ return false
365
+ }
366
+ if (!Array.isArray(entry[0])) {
367
+ return (entry as RuleEntry)[0] === 'off'
368
+ }
369
+ const variants = entry as RuleEntry[]
370
+ return variants.every((variant) => variant[0] === 'off')
371
+ }
372
+
373
+ function getPrimarySeverity(entry: RuleObject[string]): 'error' | 'warn' | 'off' {
374
+ if (!Array.isArray(entry[0])) {
375
+ const single = entry as RuleEntry
376
+ return single[0]
377
+ }
378
+
379
+ const variants = entry as RuleEntry[]
380
+ if (variants.some((variant) => variant[0] === 'error')) {
381
+ return 'error'
382
+ }
383
+ if (variants.some((variant) => variant[0] === 'warn')) {
384
+ return 'warn'
385
+ }
386
+ return 'off'
387
+ }
388
+
389
+ function toPercent(value: number, total: number): number {
390
+ if (total === 0) {
391
+ return 0
392
+ }
393
+ return Number(((value / total) * 100).toFixed(1))
394
+ }
@@ -52,61 +52,83 @@ export async function executeCheck(
52
52
  writeSkippedWorkspaceSummary(terminal, cwd, foundConfig?.path, skippedWorkspaces)
53
53
  }
54
54
  if (storedSnapshots.size === 0) {
55
- const summary = summarizeSnapshots(currentSnapshots)
56
- terminal.write(
57
- `Rules found in this analysis: ${summary.groups} groups, ${summary.rules} rules (severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off).\n`
58
- )
59
-
60
- const canPromptBaseline = defaultInvocation || format === 'summary'
61
- if (canPromptBaseline && terminal.isInteractive) {
62
- const shouldCreateBaseline = await terminal.askYesNo(
63
- 'No baseline yet. Do you want to save this analyzed rule state as your baseline now? [Y/n] ',
64
- true
65
- )
66
- if (shouldCreateBaseline) {
67
- await writeSnapshots(cwd, snapshotDir, currentSnapshots)
68
- const createdSummary = summarizeSnapshots(currentSnapshots)
69
- terminal.write(`Great start: baseline created with ${createdSummary.groups} groups and ${createdSummary.rules} rules.\n`)
70
- terminal.subtle(UPDATE_HINT)
71
- return 0
72
- }
73
- }
74
-
75
- terminal.write('You are almost set: no baseline snapshot found yet.\n')
76
- terminal.write('Run `eslint-config-snapshot --update` to create your first baseline.\n')
77
- return 1
55
+ return handleMissingBaseline(cwd, format, defaultInvocation, terminal, snapshotDir, currentSnapshots)
78
56
  }
79
57
 
80
58
  const changes = compareSnapshotMaps(storedSnapshots, currentSnapshots)
81
59
  const eslintVersionsByGroup = terminal.showProgress ? await resolveGroupEslintVersions(cwd) : new Map<string, string[]>()
82
60
 
83
61
  if (format === 'status') {
84
- if (changes.length === 0) {
85
- terminal.write('clean\n')
86
- return 0
87
- }
88
-
89
- terminal.write('changes\n')
90
- terminal.subtle(UPDATE_HINT)
91
- return 1
62
+ return handleStatusFormat(terminal, changes.length > 0)
92
63
  }
93
64
 
94
65
  if (format === 'diff') {
95
- if (changes.length === 0) {
96
- terminal.write('Great news: no snapshot changes detected.\n')
97
- writeEslintVersionSummary(terminal, eslintVersionsByGroup)
66
+ return handleDiffFormat(terminal, changes, eslintVersionsByGroup)
67
+ }
68
+
69
+ return printWhatChanged(terminal, changes, currentSnapshots, eslintVersionsByGroup)
70
+ }
71
+
72
+ async function handleMissingBaseline(
73
+ cwd: string,
74
+ format: CheckFormat,
75
+ defaultInvocation: boolean,
76
+ terminal: TerminalIO,
77
+ snapshotDir: string,
78
+ currentSnapshots: Map<string, BuiltSnapshot>
79
+ ): Promise<number> {
80
+ const summary = summarizeSnapshots(currentSnapshots)
81
+ terminal.write(
82
+ `Rules found in this analysis: ${summary.groups} groups, ${summary.rules} rules (severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off).\n`
83
+ )
84
+
85
+ const canPromptBaseline = defaultInvocation || format === 'summary'
86
+ if (canPromptBaseline && terminal.isInteractive) {
87
+ const shouldCreateBaseline = await terminal.askYesNo(
88
+ 'No baseline yet. Do you want to save this analyzed rule state as your baseline now? [Y/n] ',
89
+ true
90
+ )
91
+ if (shouldCreateBaseline) {
92
+ await writeSnapshots(cwd, snapshotDir, currentSnapshots)
93
+ const createdSummary = summarizeSnapshots(currentSnapshots)
94
+ terminal.write(`Great start: baseline created with ${createdSummary.groups} groups and ${createdSummary.rules} rules.\n`)
95
+ terminal.subtle(UPDATE_HINT)
98
96
  return 0
99
97
  }
98
+ }
100
99
 
101
- for (const change of changes) {
102
- terminal.write(`${formatDiff(change.groupId, change.diff)}\n`)
103
- }
104
- terminal.subtle(UPDATE_HINT)
100
+ terminal.write('You are almost set: no baseline snapshot found yet.\n')
101
+ terminal.write('Run `eslint-config-snapshot --update` to create your first baseline.\n')
102
+ return 1
103
+ }
105
104
 
106
- return 1
105
+ function handleStatusFormat(terminal: TerminalIO, hasChanges: boolean): number {
106
+ if (!hasChanges) {
107
+ terminal.write('clean\n')
108
+ return 0
107
109
  }
108
110
 
109
- return printWhatChanged(terminal, changes, currentSnapshots, eslintVersionsByGroup)
111
+ terminal.write('changes\n')
112
+ terminal.subtle(UPDATE_HINT)
113
+ return 1
114
+ }
115
+
116
+ function handleDiffFormat(
117
+ terminal: TerminalIO,
118
+ changes: Array<{ groupId: string; diff: SnapshotDiff }>,
119
+ eslintVersionsByGroup: GroupEslintVersions
120
+ ): number {
121
+ if (changes.length === 0) {
122
+ terminal.write('Great news: no snapshot changes detected.\n')
123
+ writeEslintVersionSummary(terminal, eslintVersionsByGroup)
124
+ return 0
125
+ }
126
+
127
+ for (const change of changes) {
128
+ terminal.write(`${formatDiff(change.groupId, change.diff)}\n`)
129
+ }
130
+ terminal.subtle(UPDATE_HINT)
131
+ return 1
110
132
  }
111
133
 
112
134
  function printWhatChanged(