@eslint-config-snapshot/cli 0.1.0 → 0.1.5

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/index.ts CHANGED
@@ -1,17 +1,17 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import {
3
- aggregateRules,
4
- assignGroupsByMatch,
5
- buildSnapshot,
6
- diffSnapshots,
7
- discoverWorkspaces,
8
- extractRulesFromPrintConfig,
9
- findConfigPath,
10
- getConfigScaffold,
11
- hasDiff,
12
- loadConfig,
13
- readSnapshotFile,
14
- sampleWorkspaceFiles,
3
+ aggregateRules,
4
+ assignGroupsByMatch,
5
+ buildSnapshot,
6
+ diffSnapshots,
7
+ discoverWorkspaces,
8
+ extractRulesFromPrintConfig,
9
+ findConfigPath,
10
+ getConfigScaffold,
11
+ hasDiff,
12
+ loadConfig,
13
+ readSnapshotFile,
14
+ sampleWorkspaceFiles,
15
15
  writeSnapshotFile
16
16
  } from '@eslint-config-snapshot/api'
17
17
  import { Command, CommanderError, InvalidArgumentError } from 'commander'
@@ -19,15 +19,15 @@ import fg from 'fast-glob'
19
19
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
20
20
  import path from 'node:path'
21
21
  import { createInterface } from 'node:readline'
22
-
23
-
22
+
23
+
24
24
  const SNAPSHOT_DIR = '.eslint-config-snapshot'
25
25
  const UPDATE_HINT = 'Tip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n'
26
-
27
- type BuiltSnapshot = Awaited<ReturnType<typeof buildSnapshot>>
28
- type StoredSnapshot = Awaited<ReturnType<typeof readSnapshotFile>>
29
- type SnapshotDiff = ReturnType<typeof diffSnapshots>
30
- type CheckFormat = 'summary' | 'status' | 'diff'
26
+
27
+ type BuiltSnapshot = Awaited<ReturnType<typeof buildSnapshot>>
28
+ type StoredSnapshot = Awaited<ReturnType<typeof readSnapshotFile>>
29
+ type SnapshotDiff = ReturnType<typeof diffSnapshots>
30
+ type CheckFormat = 'summary' | 'status' | 'diff'
31
31
  type PrintFormat = 'json' | 'short'
32
32
  type InitTarget = 'file' | 'package-json'
33
33
  type InitPreset = 'minimal' | 'full'
@@ -35,109 +35,109 @@ type InitPreset = 'minimal' | 'full'
35
35
  type RootOptions = {
36
36
  update?: boolean
37
37
  }
38
-
39
- export async function runCli(command: string | undefined, cwd: string, flags: string[] = []): Promise<number> {
40
- const argv = command ? [command, ...flags] : [...flags]
41
- return runArgv(argv, cwd)
42
- }
43
-
44
- async function runArgv(argv: string[], cwd: string): Promise<number> {
45
- const hasCommandToken = argv.some((token) => !token.startsWith('-'))
46
- if (!hasCommandToken) {
47
- return runDefaultInvocation(argv, cwd)
48
- }
49
-
50
- let actionCode: number | undefined
51
-
52
- const program = createProgram(cwd, (code) => {
53
- actionCode = code
54
- })
55
-
56
- try {
57
- await program.parseAsync(argv, { from: 'user' })
58
- } catch (error: unknown) {
59
- if (error instanceof CommanderError) {
60
- if (error.code === 'commander.helpDisplayed') {
61
- return 0
62
- }
63
- return error.exitCode
64
- }
65
-
66
- const message = error instanceof Error ? error.message : String(error)
67
- process.stderr.write(`${message}\n`)
68
- return 1
69
- }
70
-
71
- return actionCode ?? 0
72
- }
73
-
38
+
39
+ export async function runCli(command: string | undefined, cwd: string, flags: string[] = []): Promise<number> {
40
+ const argv = command ? [command, ...flags] : [...flags]
41
+ return runArgv(argv, cwd)
42
+ }
43
+
44
+ async function runArgv(argv: string[], cwd: string): Promise<number> {
45
+ const hasCommandToken = argv.some((token) => !token.startsWith('-'))
46
+ if (!hasCommandToken) {
47
+ return runDefaultInvocation(argv, cwd)
48
+ }
49
+
50
+ let actionCode: number | undefined
51
+
52
+ const program = createProgram(cwd, (code) => {
53
+ actionCode = code
54
+ })
55
+
56
+ try {
57
+ await program.parseAsync(argv, { from: 'user' })
58
+ } catch (error: unknown) {
59
+ if (error instanceof CommanderError) {
60
+ if (error.code === 'commander.helpDisplayed') {
61
+ return 0
62
+ }
63
+ return error.exitCode
64
+ }
65
+
66
+ const message = error instanceof Error ? error.message : String(error)
67
+ process.stderr.write(`${message}\n`)
68
+ return 1
69
+ }
70
+
71
+ return actionCode ?? 0
72
+ }
73
+
74
74
  async function runDefaultInvocation(argv: string[], cwd: string): Promise<number> {
75
- const known = new Set(['-u', '--update', '-h', '--help'])
76
- for (const token of argv) {
77
- if (!known.has(token)) {
78
- process.stderr.write(`error: unknown option '${token}'\n`)
79
- return 1
80
- }
81
- }
82
-
83
- if (argv.includes('-h') || argv.includes('--help')) {
84
- const program = createProgram(cwd, () => {
85
- // no-op
86
- })
87
- program.outputHelp()
88
- return 0
89
- }
90
-
91
- if (argv.includes('-u') || argv.includes('--update')) {
92
- return executeUpdate(cwd, true)
93
- }
94
-
75
+ const known = new Set(['-u', '--update', '-h', '--help'])
76
+ for (const token of argv) {
77
+ if (!known.has(token)) {
78
+ process.stderr.write(`error: unknown option '${token}'\n`)
79
+ return 1
80
+ }
81
+ }
82
+
83
+ if (argv.includes('-h') || argv.includes('--help')) {
84
+ const program = createProgram(cwd, () => {
85
+ // no-op
86
+ })
87
+ program.outputHelp()
88
+ return 0
89
+ }
90
+
91
+ if (argv.includes('-u') || argv.includes('--update')) {
92
+ return executeUpdate(cwd, true)
93
+ }
94
+
95
95
  return executeCheck(cwd, 'summary', true)
96
96
  }
97
-
98
- function createProgram(cwd: string, onActionExit: (code: number) => void): Command {
99
- const program = new Command()
100
-
101
- program
102
- .name('eslint-config-snapshot')
103
- .description('Deterministic ESLint config snapshot drift checker for workspaces')
104
- .showHelpAfterError('(add --help for usage)')
105
- .option('-u, --update', 'Update snapshots (default mode only)')
106
-
107
- program.hook('preAction', (thisCommand) => {
108
- const opts = thisCommand.opts<RootOptions>()
109
- if (opts.update) {
110
- throw new Error('--update can only be used without a command')
111
- }
112
- })
113
-
114
- program
115
- .command('check')
116
- .description('Compare current state against stored snapshots')
117
- .option('--format <format>', 'Output format: summary|status|diff', parseCheckFormat, 'summary')
118
- .action(async (opts: { format: CheckFormat }) => {
119
- onActionExit(await executeCheck(cwd, opts.format))
120
- })
121
-
122
- program
123
- .command('update')
124
- .alias('snapshot')
125
- .description('Compute and write snapshots to .eslint-config-snapshot/')
126
- .action(async () => {
127
- onActionExit(await executeUpdate(cwd, true))
128
- })
129
-
130
- program
131
- .command('print')
132
- .description('Print aggregated rules')
133
- .option('--format <format>', 'Output format: json|short', parsePrintFormat, 'json')
134
- .option('--short', 'Alias for --format short')
135
- .action(async (opts: { format: PrintFormat; short?: boolean }) => {
136
- const format: PrintFormat = opts.short ? 'short' : opts.format
137
- await executePrint(cwd, format)
138
- onActionExit(0)
139
- })
140
-
97
+
98
+ function createProgram(cwd: string, onActionExit: (code: number) => void): Command {
99
+ const program = new Command()
100
+
101
+ program
102
+ .name('eslint-config-snapshot')
103
+ .description('Deterministic ESLint config snapshot drift checker for workspaces')
104
+ .showHelpAfterError('(add --help for usage)')
105
+ .option('-u, --update', 'Update snapshots (default mode only)')
106
+
107
+ program.hook('preAction', (thisCommand) => {
108
+ const opts = thisCommand.opts<RootOptions>()
109
+ if (opts.update) {
110
+ throw new Error('--update can only be used without a command')
111
+ }
112
+ })
113
+
114
+ program
115
+ .command('check')
116
+ .description('Compare current state against stored snapshots')
117
+ .option('--format <format>', 'Output format: summary|status|diff', parseCheckFormat, 'summary')
118
+ .action(async (opts: { format: CheckFormat }) => {
119
+ onActionExit(await executeCheck(cwd, opts.format))
120
+ })
121
+
122
+ program
123
+ .command('update')
124
+ .alias('snapshot')
125
+ .description('Compute and write snapshots to .eslint-config-snapshot/')
126
+ .action(async () => {
127
+ onActionExit(await executeUpdate(cwd, true))
128
+ })
129
+
130
+ program
131
+ .command('print')
132
+ .description('Print aggregated rules')
133
+ .option('--format <format>', 'Output format: json|short', parsePrintFormat, 'json')
134
+ .option('--short', 'Alias for --format short')
135
+ .action(async (opts: { format: PrintFormat; short?: boolean }) => {
136
+ const format: PrintFormat = opts.short ? 'short' : opts.format
137
+ await executePrint(cwd, format)
138
+ onActionExit(0)
139
+ })
140
+
141
141
  program
142
142
  .command('init')
143
143
  .description('Initialize config (file or package.json)')
@@ -164,46 +164,46 @@ Examples:
164
164
  .action(async (opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean }) => {
165
165
  onActionExit(await runInit(cwd, opts))
166
166
  })
167
-
168
- // Backward-compatible aliases kept out of help.
169
- program
170
- .command('compare', { hidden: true })
171
- .action(async () => {
172
- onActionExit(await executeCheck(cwd, 'diff'))
173
- })
174
-
175
- program
176
- .command('status', { hidden: true })
177
- .action(async () => {
178
- onActionExit(await executeCheck(cwd, 'status'))
179
- })
180
-
181
- program
182
- .command('what-changed', { hidden: true })
183
- .action(async () => {
184
- onActionExit(await executeCheck(cwd, 'summary'))
185
- })
186
-
187
- program.exitOverride()
188
- return program
189
- }
190
-
191
- function parseCheckFormat(value: string): CheckFormat {
192
- const normalized = value.trim().toLowerCase()
193
- if (normalized === 'summary' || normalized === 'status' || normalized === 'diff') {
194
- return normalized
195
- }
196
-
197
- throw new InvalidArgumentError('Expected one of: summary, status, diff')
198
- }
199
-
167
+
168
+ // Backward-compatible aliases kept out of help.
169
+ program
170
+ .command('compare', { hidden: true })
171
+ .action(async () => {
172
+ onActionExit(await executeCheck(cwd, 'diff'))
173
+ })
174
+
175
+ program
176
+ .command('status', { hidden: true })
177
+ .action(async () => {
178
+ onActionExit(await executeCheck(cwd, 'status'))
179
+ })
180
+
181
+ program
182
+ .command('what-changed', { hidden: true })
183
+ .action(async () => {
184
+ onActionExit(await executeCheck(cwd, 'summary'))
185
+ })
186
+
187
+ program.exitOverride()
188
+ return program
189
+ }
190
+
191
+ function parseCheckFormat(value: string): CheckFormat {
192
+ const normalized = value.trim().toLowerCase()
193
+ if (normalized === 'summary' || normalized === 'status' || normalized === 'diff') {
194
+ return normalized
195
+ }
196
+
197
+ throw new InvalidArgumentError('Expected one of: summary, status, diff')
198
+ }
199
+
200
200
  function parsePrintFormat(value: string): PrintFormat {
201
- const normalized = value.trim().toLowerCase()
202
- if (normalized === 'json' || normalized === 'short') {
203
- return normalized
204
- }
205
-
206
- throw new InvalidArgumentError('Expected one of: json, short')
201
+ const normalized = value.trim().toLowerCase()
202
+ if (normalized === 'json' || normalized === 'short') {
203
+ return normalized
204
+ }
205
+
206
+ throw new InvalidArgumentError('Expected one of: json, short')
207
207
  }
208
208
 
209
209
  function parseInitTarget(value: string): InitTarget {
@@ -223,7 +223,7 @@ function parseInitPreset(value: string): InitPreset {
223
223
 
224
224
  throw new InvalidArgumentError('Expected one of: minimal, full')
225
225
  }
226
-
226
+
227
227
  async function executeCheck(cwd: string, format: CheckFormat, defaultInvocation = false): Promise<number> {
228
228
  const foundConfig = await findConfigPath(cwd)
229
229
  if (!foundConfig) {
@@ -272,9 +272,9 @@ async function executeCheck(cwd: string, format: CheckFormat, defaultInvocation
272
272
  process.stdout.write('Run `eslint-config-snapshot --update` to create your first baseline.\n')
273
273
  return 1
274
274
  }
275
-
276
- const changes = compareSnapshotMaps(storedSnapshots, currentSnapshots)
277
-
275
+
276
+ const changes = compareSnapshotMaps(storedSnapshots, currentSnapshots)
277
+
278
278
  if (format === 'status') {
279
279
  if (changes.length === 0) {
280
280
  process.stdout.write('clean\n')
@@ -285,13 +285,13 @@ async function executeCheck(cwd: string, format: CheckFormat, defaultInvocation
285
285
  writeSubtleInfo(UPDATE_HINT)
286
286
  return 1
287
287
  }
288
-
288
+
289
289
  if (format === 'diff') {
290
290
  if (changes.length === 0) {
291
291
  process.stdout.write('Great news: no snapshot changes detected.\n')
292
292
  return 0
293
293
  }
294
-
294
+
295
295
  for (const change of changes) {
296
296
  process.stdout.write(`${formatDiff(change.groupId, change.diff)}\n`)
297
297
  }
@@ -299,10 +299,10 @@ async function executeCheck(cwd: string, format: CheckFormat, defaultInvocation
299
299
 
300
300
  return 1
301
301
  }
302
-
303
- return printWhatChanged(changes, currentSnapshots)
304
- }
305
-
302
+
303
+ return printWhatChanged(changes, currentSnapshots)
304
+ }
305
+
306
306
  async function executeUpdate(cwd: string, printSummary: boolean): Promise<number> {
307
307
  const foundConfig = await findConfigPath(cwd)
308
308
  if (!foundConfig) {
@@ -310,7 +310,7 @@ async function executeUpdate(cwd: string, printSummary: boolean): Promise<number
310
310
  'Tip: no explicit config found. Using safe built-in defaults. Run `eslint-config-snapshot init` to customize when needed.\n'
311
311
  )
312
312
  }
313
-
313
+
314
314
  let currentSnapshots: Map<string, BuiltSnapshot>
315
315
  try {
316
316
  currentSnapshots = await computeCurrentSnapshots(cwd)
@@ -325,194 +325,194 @@ async function executeUpdate(cwd: string, printSummary: boolean): Promise<number
325
325
  throw error
326
326
  }
327
327
  await writeSnapshots(cwd, currentSnapshots)
328
-
329
- if (printSummary) {
330
- const summary = summarizeSnapshots(currentSnapshots)
328
+
329
+ if (printSummary) {
330
+ const summary = summarizeSnapshots(currentSnapshots)
331
331
  process.stdout.write(`Baseline updated: ${summary.groups} groups, ${summary.rules} rules.\n`)
332
- }
333
-
334
- return 0
335
- }
336
-
337
- async function executePrint(cwd: string, format: PrintFormat): Promise<void> {
338
- const currentSnapshots = await computeCurrentSnapshots(cwd)
339
-
340
- if (format === 'short') {
341
- process.stdout.write(formatShortPrint([...currentSnapshots.values()]))
342
- return
343
- }
344
-
345
- const output = [...currentSnapshots.values()].map((snapshot) => ({
346
- groupId: snapshot.groupId,
347
- rules: snapshot.rules
348
- }))
349
- process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
350
- }
351
-
352
- async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSnapshot>> {
353
- const config = await loadConfig(cwd)
354
- const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput })
355
-
356
- const assignments =
357
- config.grouping.mode === 'standalone'
358
- ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] }))
359
- : assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: 'default', match: ['**/*'] }])
360
-
361
- const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false
362
- if (!allowEmptyGroups) {
363
- const empty = assignments.filter((group) => group.workspaces.length === 0)
364
- if (empty.length > 0) {
365
- throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(', ')}`)
366
- }
367
- }
368
-
369
- const snapshots = new Map<string, BuiltSnapshot>()
370
-
371
- for (const group of assignments) {
372
- const extractedForGroup = []
373
-
374
- for (const workspaceRel of group.workspaces) {
375
- const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
376
- const sampled = await sampleWorkspaceFiles(workspaceAbs, config.sampling)
377
- let extractedCount = 0
378
- let lastExtractionError: string | undefined
379
-
380
- for (const sampledRel of sampled) {
381
- const sampledAbs = path.resolve(workspaceAbs, sampledRel)
382
- try {
383
- extractedForGroup.push(extractRulesFromPrintConfig(workspaceAbs, sampledAbs))
384
- extractedCount += 1
385
- } catch (error: unknown) {
386
- const message = error instanceof Error ? error.message : String(error)
387
- if (
388
- message.startsWith('Invalid JSON from eslint --print-config') ||
389
- message.startsWith('Empty ESLint print-config output')
390
- ) {
391
- lastExtractionError = message
392
- continue
393
- }
394
-
395
- throw error
396
- }
397
- }
398
-
399
- if (extractedCount === 0) {
400
- const context = lastExtractionError ? ` Last error: ${lastExtractionError}` : ''
401
- throw new Error(
402
- `Unable to extract ESLint config for workspace ${workspaceRel}. All sampled files were ignored or produced non-JSON output.${context}`
403
- )
404
- }
405
- }
406
-
407
- const aggregated = aggregateRules(extractedForGroup)
408
- snapshots.set(group.name, buildSnapshot(group.name, group.workspaces, aggregated))
409
- }
410
-
411
- return snapshots
412
- }
413
-
414
- async function loadStoredSnapshots(cwd: string): Promise<Map<string, StoredSnapshot>> {
415
- const dir = path.join(cwd, SNAPSHOT_DIR)
416
- const files = await fg('**/*.json', { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true })
417
- const snapshots = new Map<string, StoredSnapshot>()
418
- const sortedFiles = [...files].sort((a, b) => a.localeCompare(b))
419
-
420
- for (const file of sortedFiles) {
421
- const snapshot = await readSnapshotFile(file)
422
- snapshots.set(snapshot.groupId, snapshot)
423
- }
424
-
425
- return snapshots
426
- }
427
-
428
- async function writeSnapshots(cwd: string, snapshots: Map<string, BuiltSnapshot>): Promise<void> {
429
- await mkdir(path.join(cwd, SNAPSHOT_DIR), { recursive: true })
430
- for (const snapshot of snapshots.values()) {
431
- await writeSnapshotFile(path.join(cwd, SNAPSHOT_DIR), snapshot)
432
- }
433
- }
434
-
435
- function compareSnapshotMaps(before: Map<string, StoredSnapshot>, after: Map<string, BuiltSnapshot>) {
436
- const ids = [...new Set([...before.keys(), ...after.keys()])].sort()
437
- const changes: Array<{ groupId: string; diff: SnapshotDiff }> = []
438
-
439
- for (const id of ids) {
440
- const prev =
441
- before.get(id) ??
442
- ({
443
- formatVersion: 1,
444
- groupId: id,
445
- workspaces: [],
446
- rules: {}
447
- } as const)
448
-
449
- const next =
450
- after.get(id) ??
451
- ({
452
- formatVersion: 1,
453
- groupId: id,
454
- workspaces: [],
455
- rules: {}
456
- } as const)
457
-
458
- const diff = diffSnapshots(prev, next)
459
- if (hasDiff(diff)) {
460
- changes.push({ groupId: id, diff })
461
- }
462
- }
463
-
464
- return changes
465
- }
466
-
467
- function formatDiff(groupId: string, diff: SnapshotDiff): string {
468
- const lines = [`group: ${groupId}`]
469
-
470
- addListSection(lines, 'introduced rules', diff.introducedRules)
471
- addListSection(lines, 'removed rules', diff.removedRules)
472
-
473
- if (diff.severityChanges.length > 0) {
474
- lines.push('severity changed:')
475
- for (const change of diff.severityChanges) {
476
- lines.push(` - ${change.rule}: ${change.before} -> ${change.after}`)
477
- }
478
- }
479
-
480
- const optionChanges = getDisplayOptionChanges(diff)
481
- if (optionChanges.length > 0) {
482
- lines.push('options changed:')
483
- for (const change of optionChanges) {
484
- lines.push(` - ${change.rule}: ${formatValue(change.before)} -> ${formatValue(change.after)}`)
485
- }
486
- }
487
-
488
- addListSection(lines, 'workspaces added', diff.workspaceMembershipChanges.added)
489
- addListSection(lines, 'workspaces removed', diff.workspaceMembershipChanges.removed)
490
-
491
- return lines.join('\n')
492
- }
493
-
494
- function addListSection(lines: string[], title: string, values: string[]): void {
495
- if (values.length === 0) {
496
- return
497
- }
498
-
499
- lines.push(`${title}:`)
500
- for (const value of values) {
501
- lines.push(` - ${value}`)
502
- }
503
- }
504
-
505
- function formatValue(value: unknown): string {
506
- const serialized = JSON.stringify(value)
507
- return serialized === undefined ? 'undefined' : serialized
508
- }
509
-
510
- function getDisplayOptionChanges(diff: SnapshotDiff): SnapshotDiff['optionChanges'] {
511
- const removedRules = new Set(diff.removedRules)
512
- const severityChangedRules = new Set(diff.severityChanges.map((change) => change.rule))
513
- return diff.optionChanges.filter((change) => !removedRules.has(change.rule) && !severityChangedRules.has(change.rule))
514
- }
515
-
332
+ }
333
+
334
+ return 0
335
+ }
336
+
337
+ async function executePrint(cwd: string, format: PrintFormat): Promise<void> {
338
+ const currentSnapshots = await computeCurrentSnapshots(cwd)
339
+
340
+ if (format === 'short') {
341
+ process.stdout.write(formatShortPrint([...currentSnapshots.values()]))
342
+ return
343
+ }
344
+
345
+ const output = [...currentSnapshots.values()].map((snapshot) => ({
346
+ groupId: snapshot.groupId,
347
+ rules: snapshot.rules
348
+ }))
349
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
350
+ }
351
+
352
+ async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSnapshot>> {
353
+ const config = await loadConfig(cwd)
354
+ const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput })
355
+
356
+ const assignments =
357
+ config.grouping.mode === 'standalone'
358
+ ? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] }))
359
+ : assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: 'default', match: ['**/*'] }])
360
+
361
+ const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false
362
+ if (!allowEmptyGroups) {
363
+ const empty = assignments.filter((group) => group.workspaces.length === 0)
364
+ if (empty.length > 0) {
365
+ throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(', ')}`)
366
+ }
367
+ }
368
+
369
+ const snapshots = new Map<string, BuiltSnapshot>()
370
+
371
+ for (const group of assignments) {
372
+ const extractedForGroup = []
373
+
374
+ for (const workspaceRel of group.workspaces) {
375
+ const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
376
+ const sampled = await sampleWorkspaceFiles(workspaceAbs, config.sampling)
377
+ let extractedCount = 0
378
+ let lastExtractionError: string | undefined
379
+
380
+ for (const sampledRel of sampled) {
381
+ const sampledAbs = path.resolve(workspaceAbs, sampledRel)
382
+ try {
383
+ extractedForGroup.push(extractRulesFromPrintConfig(workspaceAbs, sampledAbs))
384
+ extractedCount += 1
385
+ } catch (error: unknown) {
386
+ const message = error instanceof Error ? error.message : String(error)
387
+ if (
388
+ message.startsWith('Invalid JSON from eslint --print-config') ||
389
+ message.startsWith('Empty ESLint print-config output')
390
+ ) {
391
+ lastExtractionError = message
392
+ continue
393
+ }
394
+
395
+ throw error
396
+ }
397
+ }
398
+
399
+ if (extractedCount === 0) {
400
+ const context = lastExtractionError ? ` Last error: ${lastExtractionError}` : ''
401
+ throw new Error(
402
+ `Unable to extract ESLint config for workspace ${workspaceRel}. All sampled files were ignored or produced non-JSON output.${context}`
403
+ )
404
+ }
405
+ }
406
+
407
+ const aggregated = aggregateRules(extractedForGroup)
408
+ snapshots.set(group.name, buildSnapshot(group.name, group.workspaces, aggregated))
409
+ }
410
+
411
+ return snapshots
412
+ }
413
+
414
+ async function loadStoredSnapshots(cwd: string): Promise<Map<string, StoredSnapshot>> {
415
+ const dir = path.join(cwd, SNAPSHOT_DIR)
416
+ const files = await fg('**/*.json', { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true })
417
+ const snapshots = new Map<string, StoredSnapshot>()
418
+ const sortedFiles = [...files].sort((a, b) => a.localeCompare(b))
419
+
420
+ for (const file of sortedFiles) {
421
+ const snapshot = await readSnapshotFile(file)
422
+ snapshots.set(snapshot.groupId, snapshot)
423
+ }
424
+
425
+ return snapshots
426
+ }
427
+
428
+ async function writeSnapshots(cwd: string, snapshots: Map<string, BuiltSnapshot>): Promise<void> {
429
+ await mkdir(path.join(cwd, SNAPSHOT_DIR), { recursive: true })
430
+ for (const snapshot of snapshots.values()) {
431
+ await writeSnapshotFile(path.join(cwd, SNAPSHOT_DIR), snapshot)
432
+ }
433
+ }
434
+
435
+ function compareSnapshotMaps(before: Map<string, StoredSnapshot>, after: Map<string, BuiltSnapshot>) {
436
+ const ids = [...new Set([...before.keys(), ...after.keys()])].sort()
437
+ const changes: Array<{ groupId: string; diff: SnapshotDiff }> = []
438
+
439
+ for (const id of ids) {
440
+ const prev =
441
+ before.get(id) ??
442
+ ({
443
+ formatVersion: 1,
444
+ groupId: id,
445
+ workspaces: [],
446
+ rules: {}
447
+ } as const)
448
+
449
+ const next =
450
+ after.get(id) ??
451
+ ({
452
+ formatVersion: 1,
453
+ groupId: id,
454
+ workspaces: [],
455
+ rules: {}
456
+ } as const)
457
+
458
+ const diff = diffSnapshots(prev, next)
459
+ if (hasDiff(diff)) {
460
+ changes.push({ groupId: id, diff })
461
+ }
462
+ }
463
+
464
+ return changes
465
+ }
466
+
467
+ function formatDiff(groupId: string, diff: SnapshotDiff): string {
468
+ const lines = [`group: ${groupId}`]
469
+
470
+ addListSection(lines, 'introduced rules', diff.introducedRules)
471
+ addListSection(lines, 'removed rules', diff.removedRules)
472
+
473
+ if (diff.severityChanges.length > 0) {
474
+ lines.push('severity changed:')
475
+ for (const change of diff.severityChanges) {
476
+ lines.push(` - ${change.rule}: ${change.before} -> ${change.after}`)
477
+ }
478
+ }
479
+
480
+ const optionChanges = getDisplayOptionChanges(diff)
481
+ if (optionChanges.length > 0) {
482
+ lines.push('options changed:')
483
+ for (const change of optionChanges) {
484
+ lines.push(` - ${change.rule}: ${formatValue(change.before)} -> ${formatValue(change.after)}`)
485
+ }
486
+ }
487
+
488
+ addListSection(lines, 'workspaces added', diff.workspaceMembershipChanges.added)
489
+ addListSection(lines, 'workspaces removed', diff.workspaceMembershipChanges.removed)
490
+
491
+ return lines.join('\n')
492
+ }
493
+
494
+ function addListSection(lines: string[], title: string, values: string[]): void {
495
+ if (values.length === 0) {
496
+ return
497
+ }
498
+
499
+ lines.push(`${title}:`)
500
+ for (const value of values) {
501
+ lines.push(` - ${value}`)
502
+ }
503
+ }
504
+
505
+ function formatValue(value: unknown): string {
506
+ const serialized = JSON.stringify(value)
507
+ return serialized === undefined ? 'undefined' : serialized
508
+ }
509
+
510
+ function getDisplayOptionChanges(diff: SnapshotDiff): SnapshotDiff['optionChanges'] {
511
+ const removedRules = new Set(diff.removedRules)
512
+ const severityChangedRules = new Set(diff.severityChanges.map((change) => change.rule))
513
+ return diff.optionChanges.filter((change) => !removedRules.has(change.rule) && !severityChangedRules.has(change.rule))
514
+ }
515
+
516
516
  async function runInit(
517
517
  cwd: string,
518
518
  opts: { target?: InitTarget; preset?: InitPreset; force?: boolean; yes?: boolean } = {}
@@ -635,12 +635,12 @@ async function runInitInFile(cwd: string, preset: InitPreset, force: boolean): P
635
635
  const candidates = [
636
636
  '.eslint-config-snapshot.js',
637
637
  '.eslint-config-snapshot.cjs',
638
- '.eslint-config-snapshot.mjs',
639
- 'eslint-config-snapshot.config.js',
640
- 'eslint-config-snapshot.config.cjs',
641
- 'eslint-config-snapshot.config.mjs'
642
- ]
643
-
638
+ '.eslint-config-snapshot.mjs',
639
+ 'eslint-config-snapshot.config.js',
640
+ 'eslint-config-snapshot.config.cjs',
641
+ 'eslint-config-snapshot.config.mjs'
642
+ ]
643
+
644
644
  for (const candidate of candidates) {
645
645
  try {
646
646
  await access(path.join(cwd, candidate))
@@ -652,7 +652,7 @@ async function runInitInFile(cwd: string, preset: InitPreset, force: boolean): P
652
652
  // continue
653
653
  }
654
654
  }
655
-
655
+
656
656
  const target = path.join(cwd, 'eslint-config-snapshot.config.mjs')
657
657
  await writeFile(target, getConfigScaffold(preset), 'utf8')
658
658
  process.stdout.write(`Created ${path.basename(target)}\n`)
@@ -704,7 +704,7 @@ function getFullPresetObject() {
704
704
  }
705
705
  }
706
706
  }
707
-
707
+
708
708
  export async function main(): Promise<void> {
709
709
  const code = await runArgv(process.argv.slice(2), process.cwd())
710
710
  process.exit(code)
@@ -723,12 +723,12 @@ function isDirectCliExecution(): boolean {
723
723
  if (isDirectCliExecution()) {
724
724
  void main()
725
725
  }
726
-
727
- function printWhatChanged(changes: Array<{ groupId: string; diff: SnapshotDiff }>, currentSnapshots: Map<string, BuiltSnapshot>): number {
728
- const color = createColorizer()
729
- const currentSummary = summarizeSnapshots(currentSnapshots)
730
- const changeSummary = summarizeChanges(changes)
731
-
726
+
727
+ function printWhatChanged(changes: Array<{ groupId: string; diff: SnapshotDiff }>, currentSnapshots: Map<string, BuiltSnapshot>): number {
728
+ const color = createColorizer()
729
+ const currentSummary = summarizeSnapshots(currentSnapshots)
730
+ const changeSummary = summarizeChanges(changes)
731
+
732
732
  if (changes.length === 0) {
733
733
  process.stdout.write(color.green('Great news: no snapshot drift detected.\n'))
734
734
  process.stdout.write(
@@ -738,76 +738,76 @@ function printWhatChanged(changes: Array<{ groupId: string; diff: SnapshotDiff }
738
738
  }
739
739
 
740
740
  process.stdout.write(color.red('Heads up: snapshot drift detected.\n'))
741
- process.stdout.write(
742
- `Changed groups: ${changes.length} | introduced: ${changeSummary.introduced} | removed: ${changeSummary.removed} | severity: ${changeSummary.severity} | options: ${changeSummary.options} | workspace membership: ${changeSummary.workspace}\n`
743
- )
741
+ process.stdout.write(
742
+ `Changed groups: ${changes.length} | introduced: ${changeSummary.introduced} | removed: ${changeSummary.removed} | severity: ${changeSummary.severity} | options: ${changeSummary.options} | workspace membership: ${changeSummary.workspace}\n`
743
+ )
744
744
  process.stdout.write(
745
745
  `Current rules: ${currentSummary.rules} (severity mix: ${currentSummary.error} errors, ${currentSummary.warn} warnings, ${currentSummary.off} off)\n\n`
746
746
  )
747
-
747
+
748
748
  for (const change of changes) {
749
- process.stdout.write(color.bold(`group ${change.groupId}\n`))
750
- const lines = formatDiff(change.groupId, change.diff).split('\n').slice(1)
751
- for (const line of lines) {
752
- const decorated = decorateDiffLine(line, color)
753
- process.stdout.write(`${decorated}\n`)
754
- }
755
- process.stdout.write('\n')
749
+ process.stdout.write(color.bold(`group ${change.groupId}\n`))
750
+ const lines = formatDiff(change.groupId, change.diff).split('\n').slice(1)
751
+ for (const line of lines) {
752
+ const decorated = decorateDiffLine(line, color)
753
+ process.stdout.write(`${decorated}\n`)
754
+ }
755
+ process.stdout.write('\n')
756
756
  }
757
757
  writeSubtleInfo(UPDATE_HINT)
758
758
 
759
759
  return 1
760
760
  }
761
-
762
- function summarizeChanges(changes: Array<{ groupId: string; diff: SnapshotDiff }>) {
763
- let introduced = 0
764
- let removed = 0
765
- let severity = 0
766
- let options = 0
767
- let workspace = 0
768
- for (const change of changes) {
769
- introduced += change.diff.introducedRules.length
770
- removed += change.diff.removedRules.length
771
- severity += change.diff.severityChanges.length
772
- options += getDisplayOptionChanges(change.diff).length
773
- workspace += change.diff.workspaceMembershipChanges.added.length + change.diff.workspaceMembershipChanges.removed.length
774
- }
775
- return { introduced, removed, severity, options, workspace }
776
- }
777
-
778
- function summarizeSnapshots(snapshots: Map<string, BuiltSnapshot>) {
779
- let rules = 0
780
- let error = 0
781
- let warn = 0
782
- let off = 0
783
- for (const snapshot of snapshots.values()) {
784
- for (const entry of Object.values(snapshot.rules)) {
785
- rules += 1
786
- if (entry[0] === 'error') {
787
- error += 1
788
- } else if (entry[0] === 'warn') {
789
- warn += 1
790
- } else {
791
- off += 1
792
- }
793
- }
794
- }
795
- return { groups: snapshots.size, rules, error, warn, off }
796
- }
797
-
798
- function decorateDiffLine(line: string, color: ReturnType<typeof createColorizer>): string {
799
- if (line.startsWith('introduced rules:') || line.startsWith('workspaces added:')) {
800
- return color.green(`+ ${line}`)
801
- }
802
- if (line.startsWith('removed rules:') || line.startsWith('workspaces removed:')) {
803
- return color.red(`- ${line}`)
804
- }
805
- if (line.startsWith('severity changed:') || line.startsWith('options changed:')) {
806
- return color.yellow(`~ ${line}`)
807
- }
808
- return line
809
- }
810
-
761
+
762
+ function summarizeChanges(changes: Array<{ groupId: string; diff: SnapshotDiff }>) {
763
+ let introduced = 0
764
+ let removed = 0
765
+ let severity = 0
766
+ let options = 0
767
+ let workspace = 0
768
+ for (const change of changes) {
769
+ introduced += change.diff.introducedRules.length
770
+ removed += change.diff.removedRules.length
771
+ severity += change.diff.severityChanges.length
772
+ options += getDisplayOptionChanges(change.diff).length
773
+ workspace += change.diff.workspaceMembershipChanges.added.length + change.diff.workspaceMembershipChanges.removed.length
774
+ }
775
+ return { introduced, removed, severity, options, workspace }
776
+ }
777
+
778
+ function summarizeSnapshots(snapshots: Map<string, BuiltSnapshot>) {
779
+ let rules = 0
780
+ let error = 0
781
+ let warn = 0
782
+ let off = 0
783
+ for (const snapshot of snapshots.values()) {
784
+ for (const entry of Object.values(snapshot.rules)) {
785
+ rules += 1
786
+ if (entry[0] === 'error') {
787
+ error += 1
788
+ } else if (entry[0] === 'warn') {
789
+ warn += 1
790
+ } else {
791
+ off += 1
792
+ }
793
+ }
794
+ }
795
+ return { groups: snapshots.size, rules, error, warn, off }
796
+ }
797
+
798
+ function decorateDiffLine(line: string, color: ReturnType<typeof createColorizer>): string {
799
+ if (line.startsWith('introduced rules:') || line.startsWith('workspaces added:')) {
800
+ return color.green(`+ ${line}`)
801
+ }
802
+ if (line.startsWith('removed rules:') || line.startsWith('workspaces removed:')) {
803
+ return color.red(`- ${line}`)
804
+ }
805
+ if (line.startsWith('severity changed:') || line.startsWith('options changed:')) {
806
+ return color.yellow(`~ ${line}`)
807
+ }
808
+ return line
809
+ }
810
+
811
811
  function createColorizer() {
812
812
  const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'
813
813
  const wrap = (code: string, text: string) => (enabled ? `\u001B[${code}m${text}\u001B[0m` : text)
@@ -824,38 +824,38 @@ function writeSubtleInfo(text: string): void {
824
824
  const color = createColorizer()
825
825
  process.stdout.write(color.dim(text))
826
826
  }
827
-
828
- function formatShortPrint(
829
- snapshots: Array<{
830
- groupId: string
831
- workspaces: string[]
832
- rules: Record<string, [severity: 'off' | 'warn' | 'error'] | [severity: 'off' | 'warn' | 'error', options: unknown]>
833
- }>
834
- ): string {
835
- const lines: string[] = []
836
- const sorted = [...snapshots].sort((a, b) => a.groupId.localeCompare(b.groupId))
837
-
838
- for (const snapshot of sorted) {
839
- const ruleNames = Object.keys(snapshot.rules).sort()
840
- const severityCounts = { error: 0, warn: 0, off: 0 }
841
-
842
- for (const name of ruleNames) {
843
- const severity = snapshot.rules[name][0]
844
- severityCounts[severity] += 1
845
- }
846
-
847
- lines.push(
848
- `group: ${snapshot.groupId}`,
849
- `workspaces (${snapshot.workspaces.length}): ${snapshot.workspaces.length > 0 ? snapshot.workspaces.join(', ') : '(none)'}`,
850
- `rules (${ruleNames.length}): error ${severityCounts.error}, warn ${severityCounts.warn}, off ${severityCounts.off}`
851
- )
852
-
853
- for (const ruleName of ruleNames) {
854
- const entry = snapshot.rules[ruleName]
855
- const suffix = entry.length > 1 ? ` ${JSON.stringify(entry[1])}` : ''
856
- lines.push(`${ruleName}: ${entry[0]}${suffix}`)
857
- }
858
- }
859
-
860
- return `${lines.join('\n')}\n`
861
- }
827
+
828
+ function formatShortPrint(
829
+ snapshots: Array<{
830
+ groupId: string
831
+ workspaces: string[]
832
+ rules: Record<string, [severity: 'off' | 'warn' | 'error'] | [severity: 'off' | 'warn' | 'error', options: unknown]>
833
+ }>
834
+ ): string {
835
+ const lines: string[] = []
836
+ const sorted = [...snapshots].sort((a, b) => a.groupId.localeCompare(b.groupId))
837
+
838
+ for (const snapshot of sorted) {
839
+ const ruleNames = Object.keys(snapshot.rules).sort()
840
+ const severityCounts = { error: 0, warn: 0, off: 0 }
841
+
842
+ for (const name of ruleNames) {
843
+ const severity = snapshot.rules[name][0]
844
+ severityCounts[severity] += 1
845
+ }
846
+
847
+ lines.push(
848
+ `group: ${snapshot.groupId}`,
849
+ `workspaces (${snapshot.workspaces.length}): ${snapshot.workspaces.length > 0 ? snapshot.workspaces.join(', ') : '(none)'}`,
850
+ `rules (${ruleNames.length}): error ${severityCounts.error}, warn ${severityCounts.warn}, off ${severityCounts.off}`
851
+ )
852
+
853
+ for (const ruleName of ruleNames) {
854
+ const entry = snapshot.rules[ruleName]
855
+ const suffix = entry.length > 1 ? ` ${JSON.stringify(entry[1])}` : ''
856
+ lines.push(`${ruleName}: ${entry[0]}${suffix}`)
857
+ }
858
+ }
859
+
860
+ return `${lines.join('\n')}\n`
861
+ }