@eslint-config-snapshot/cli 0.9.0 → 0.14.1

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": "0.9.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,8 @@
29
29
  "dependencies": {
30
30
  "@inquirer/prompts": "^8.2.0",
31
31
  "commander": "^14.0.3",
32
+ "debug": "^4.4.3",
32
33
  "fast-glob": "^3.3.3",
33
- "@eslint-config-snapshot/api": "0.9.0"
34
+ "@eslint-config-snapshot/api": "0.14.1"
34
35
  }
35
36
  }
@@ -0,0 +1,157 @@
1
+ import { findConfigPath } from '@eslint-config-snapshot/api'
2
+
3
+ import { countUniqueWorkspaces, decorateDiffLine, formatDiff, summarizeChanges, summarizeSnapshots } from '../formatters.js'
4
+ import { writeEslintVersionSummary, writeRunContextHeader } from '../run-context.js'
5
+ import {
6
+ type BuiltSnapshot,
7
+ compareSnapshotMaps,
8
+ computeCurrentSnapshots,
9
+ type GroupEslintVersions,
10
+ loadStoredSnapshots,
11
+ resolveGroupEslintVersions,
12
+ type SnapshotDiff,
13
+ writeSnapshots
14
+ } from '../runtime.js'
15
+ import { type TerminalIO } from '../terminal.js'
16
+
17
+ export type CheckFormat = 'summary' | 'status' | 'diff'
18
+
19
+ const UPDATE_HINT = 'Tip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n'
20
+
21
+ export async function executeCheck(
22
+ cwd: string,
23
+ format: CheckFormat,
24
+ terminal: TerminalIO,
25
+ snapshotDir: string,
26
+ defaultInvocation = false
27
+ ): Promise<number> {
28
+ const foundConfig = await findConfigPath(cwd)
29
+ const storedSnapshots = await loadStoredSnapshots(cwd, snapshotDir)
30
+
31
+ if (format !== 'status') {
32
+ writeRunContextHeader(terminal, cwd, defaultInvocation ? 'check' : `check:${format}`, foundConfig?.path, storedSnapshots)
33
+ if (terminal.showProgress) {
34
+ terminal.subtle('🔎 Checking current ESLint configuration...\n')
35
+ }
36
+ }
37
+
38
+ if (!foundConfig) {
39
+ terminal.subtle(
40
+ 'Tip: no explicit config found. Using safe built-in defaults. Run `eslint-config-snapshot init` to customize when needed.\n'
41
+ )
42
+ }
43
+
44
+ let currentSnapshots: Map<string, BuiltSnapshot>
45
+ try {
46
+ currentSnapshots = await computeCurrentSnapshots(cwd)
47
+ } catch (error: unknown) {
48
+ if (!foundConfig) {
49
+ terminal.write(
50
+ 'Automatic workspace discovery could not complete with defaults.\nRun `eslint-config-snapshot init` to configure workspaces, then run `eslint-config-snapshot --update`.\n'
51
+ )
52
+ return 1
53
+ }
54
+
55
+ throw error
56
+ }
57
+ if (storedSnapshots.size === 0) {
58
+ const summary = summarizeSnapshots(currentSnapshots)
59
+ terminal.write(
60
+ `Rules found in this analysis: ${summary.groups} groups, ${summary.rules} rules (severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off).\n`
61
+ )
62
+
63
+ const canPromptBaseline = defaultInvocation || format === 'summary'
64
+ if (canPromptBaseline && terminal.isInteractive) {
65
+ const shouldCreateBaseline = await terminal.askYesNo(
66
+ 'No baseline yet. Do you want to save this analyzed rule state as your baseline now? [Y/n] ',
67
+ true
68
+ )
69
+ if (shouldCreateBaseline) {
70
+ await writeSnapshots(cwd, snapshotDir, currentSnapshots)
71
+ const createdSummary = summarizeSnapshots(currentSnapshots)
72
+ terminal.write(`Great start: baseline created with ${createdSummary.groups} groups and ${createdSummary.rules} rules.\n`)
73
+ terminal.subtle(UPDATE_HINT)
74
+ return 0
75
+ }
76
+ }
77
+
78
+ terminal.write('You are almost set: no baseline snapshot found yet.\n')
79
+ terminal.write('Run `eslint-config-snapshot --update` to create your first baseline.\n')
80
+ return 1
81
+ }
82
+
83
+ const changes = compareSnapshotMaps(storedSnapshots, currentSnapshots)
84
+ const eslintVersionsByGroup = terminal.showProgress ? await resolveGroupEslintVersions(cwd) : new Map<string, string[]>()
85
+
86
+ if (format === 'status') {
87
+ if (changes.length === 0) {
88
+ terminal.write('clean\n')
89
+ return 0
90
+ }
91
+
92
+ terminal.write('changes\n')
93
+ terminal.subtle(UPDATE_HINT)
94
+ return 1
95
+ }
96
+
97
+ if (format === 'diff') {
98
+ if (changes.length === 0) {
99
+ terminal.write('Great news: no snapshot changes detected.\n')
100
+ writeEslintVersionSummary(terminal, eslintVersionsByGroup)
101
+ return 0
102
+ }
103
+
104
+ for (const change of changes) {
105
+ terminal.write(`${formatDiff(change.groupId, change.diff)}\n`)
106
+ }
107
+ terminal.subtle(UPDATE_HINT)
108
+
109
+ return 1
110
+ }
111
+
112
+ return printWhatChanged(terminal, changes, currentSnapshots, eslintVersionsByGroup)
113
+ }
114
+
115
+ function printWhatChanged(
116
+ terminal: TerminalIO,
117
+ changes: Array<{ groupId: string; diff: SnapshotDiff }>,
118
+ currentSnapshots: Map<string, BuiltSnapshot>,
119
+ eslintVersionsByGroup: GroupEslintVersions
120
+ ): number {
121
+ const color = terminal.colors
122
+ const currentSummary = summarizeSnapshots(currentSnapshots)
123
+ const workspaceCount = countUniqueWorkspaces(currentSnapshots)
124
+ const changeSummary = summarizeChanges(changes)
125
+
126
+ if (changes.length === 0) {
127
+ terminal.write(color.green('✅ Great news: no snapshot drift detected.\n'))
128
+ terminal.section('📊 Summary')
129
+ terminal.write(
130
+ `- 📦 baseline: ${currentSummary.groups} groups, ${currentSummary.rules} rules\n- 🗂️ workspaces scanned: ${workspaceCount}\n- 🎚️ severity mix: ${currentSummary.error} errors, ${currentSummary.warn} warnings, ${currentSummary.off} off\n`
131
+ )
132
+ writeEslintVersionSummary(terminal, eslintVersionsByGroup)
133
+ return 0
134
+ }
135
+
136
+ terminal.write(color.red('⚠️ Heads up: snapshot drift detected.\n'))
137
+ terminal.section('📊 Summary')
138
+ terminal.write(
139
+ `- changed groups: ${changes.length}\n- introduced rules: ${changeSummary.introduced}\n- removed rules: ${changeSummary.removed}\n- severity changes: ${changeSummary.severity}\n- options changes: ${changeSummary.options}\n- workspace membership changes: ${changeSummary.workspace}\n- 🗂️ workspaces scanned: ${workspaceCount}\n- current baseline: ${currentSummary.groups} groups, ${currentSummary.rules} rules\n- current severity mix: ${currentSummary.error} errors, ${currentSummary.warn} warnings, ${currentSummary.off} off\n`
140
+ )
141
+ writeEslintVersionSummary(terminal, eslintVersionsByGroup)
142
+ terminal.write('\n')
143
+
144
+ terminal.section('🧾 Changes')
145
+ for (const change of changes) {
146
+ terminal.write(color.bold(`group ${change.groupId}\n`))
147
+ const lines = formatDiff(change.groupId, change.diff).split('\n').slice(1)
148
+ for (const line of lines) {
149
+ const decorated = decorateDiffLine(line, color)
150
+ terminal.write(`${decorated}\n`)
151
+ }
152
+ terminal.write('\n')
153
+ }
154
+ terminal.subtle(UPDATE_HINT)
155
+
156
+ return 1
157
+ }
@@ -0,0 +1,58 @@
1
+ import { findConfigPath, loadConfig } from '@eslint-config-snapshot/api'
2
+
3
+ import { formatShortConfig, formatShortPrint } from '../formatters.js'
4
+ import { writeRunContextHeader } from '../run-context.js'
5
+ import { computeCurrentSnapshots, loadStoredSnapshots, resolveWorkspaceAssignments, type WorkspaceAssignments } from '../runtime.js'
6
+ import { type TerminalIO } from '../terminal.js'
7
+
8
+ export type PrintFormat = 'json' | 'short'
9
+
10
+ export async function executePrint(cwd: string, terminal: TerminalIO, snapshotDir: string, format: PrintFormat): Promise<void> {
11
+ const foundConfig = await findConfigPath(cwd)
12
+ const storedSnapshots = await loadStoredSnapshots(cwd, snapshotDir)
13
+ writeRunContextHeader(terminal, cwd, `print:${format}`, foundConfig?.path, storedSnapshots)
14
+ if (terminal.showProgress) {
15
+ terminal.subtle('🔎 Checking current ESLint configuration...\n')
16
+ }
17
+ const currentSnapshots = await computeCurrentSnapshots(cwd)
18
+
19
+ if (format === 'short') {
20
+ terminal.write(formatShortPrint([...currentSnapshots.values()]))
21
+ return
22
+ }
23
+
24
+ const output = [...currentSnapshots.values()].map((snapshot) => ({
25
+ groupId: snapshot.groupId,
26
+ rules: snapshot.rules
27
+ }))
28
+ terminal.write(`${JSON.stringify(output, null, 2)}\n`)
29
+ }
30
+
31
+ export async function executeConfig(cwd: string, terminal: TerminalIO, snapshotDir: string, format: PrintFormat): Promise<void> {
32
+ const foundConfig = await findConfigPath(cwd)
33
+ const storedSnapshots = await loadStoredSnapshots(cwd, snapshotDir)
34
+ writeRunContextHeader(terminal, cwd, `config:${format}`, foundConfig?.path, storedSnapshots)
35
+ if (terminal.showProgress) {
36
+ terminal.subtle('⚙️ Resolving effective runtime configuration...\n')
37
+ }
38
+ const config = await loadConfig(cwd)
39
+ const resolved: WorkspaceAssignments = await resolveWorkspaceAssignments(cwd, config)
40
+ const payload = {
41
+ source: foundConfig?.path ?? 'built-in-defaults',
42
+ workspaceInput: config.workspaceInput,
43
+ workspaces: resolved.discovery.workspacesRel,
44
+ grouping: {
45
+ mode: config.grouping.mode,
46
+ allowEmptyGroups: config.grouping.allowEmptyGroups ?? false,
47
+ groups: resolved.assignments.map((entry) => ({ name: entry.name, workspaces: entry.workspaces }))
48
+ },
49
+ sampling: config.sampling
50
+ }
51
+
52
+ if (format === 'short') {
53
+ terminal.write(formatShortConfig(payload))
54
+ return
55
+ }
56
+
57
+ terminal.write(`${JSON.stringify(payload, null, 2)}\n`)
58
+ }
@@ -0,0 +1,49 @@
1
+ import { findConfigPath } from '@eslint-config-snapshot/api'
2
+
3
+ import { countUniqueWorkspaces, summarizeSnapshots } from '../formatters.js'
4
+ import { writeEslintVersionSummary, writeRunContextHeader } from '../run-context.js'
5
+ import { computeCurrentSnapshots, loadStoredSnapshots, resolveGroupEslintVersions, writeSnapshots } from '../runtime.js'
6
+ import { type TerminalIO } from '../terminal.js'
7
+
8
+ export async function executeUpdate(cwd: string, terminal: TerminalIO, snapshotDir: string, printSummary: boolean): Promise<number> {
9
+ const foundConfig = await findConfigPath(cwd)
10
+ const storedSnapshots = await loadStoredSnapshots(cwd, snapshotDir)
11
+ writeRunContextHeader(terminal, cwd, 'update', foundConfig?.path, storedSnapshots)
12
+ if (terminal.showProgress) {
13
+ terminal.subtle('🔎 Checking current ESLint configuration...\n')
14
+ }
15
+
16
+ if (!foundConfig) {
17
+ terminal.subtle(
18
+ 'Tip: no explicit config found. Using safe built-in defaults. Run `eslint-config-snapshot init` to customize when needed.\n'
19
+ )
20
+ }
21
+
22
+ let currentSnapshots
23
+ try {
24
+ currentSnapshots = await computeCurrentSnapshots(cwd)
25
+ } catch (error: unknown) {
26
+ if (!foundConfig) {
27
+ terminal.write(
28
+ 'Automatic workspace discovery could not complete with defaults.\nRun `eslint-config-snapshot init` to configure workspaces, then run `eslint-config-snapshot --update`.\n'
29
+ )
30
+ return 1
31
+ }
32
+
33
+ throw error
34
+ }
35
+ await writeSnapshots(cwd, snapshotDir, currentSnapshots)
36
+
37
+ if (printSummary) {
38
+ const summary = summarizeSnapshots(currentSnapshots)
39
+ const workspaceCount = countUniqueWorkspaces(currentSnapshots)
40
+ const eslintVersionsByGroup = terminal.showProgress ? await resolveGroupEslintVersions(cwd) : new Map<string, string[]>()
41
+ terminal.section('📊 Summary')
42
+ terminal.write(
43
+ `Baseline updated: ${summary.groups} groups, ${summary.rules} rules.\nWorkspaces scanned: ${workspaceCount}.\nSeverity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off.\n`
44
+ )
45
+ writeEslintVersionSummary(terminal, eslintVersionsByGroup)
46
+ }
47
+
48
+ return 0
49
+ }
@@ -0,0 +1,256 @@
1
+ import type { SnapshotDiff, SnapshotRuleEntry } from '@eslint-config-snapshot/api'
2
+
3
+ export type RuleEntry = [severity: 'off' | 'warn' | 'error'] | [severity: 'off' | 'warn' | 'error', options: unknown]
4
+ export type RuleObject = Record<string, SnapshotRuleEntry>
5
+
6
+ export type SnapshotLike = {
7
+ groupId: string
8
+ workspaces: string[]
9
+ rules: RuleObject
10
+ }
11
+
12
+ export function formatDiff(groupId: string, diff: SnapshotDiff): string {
13
+ const lines = [`group: ${groupId}`]
14
+
15
+ addListSection(lines, 'introduced rules', diff.introducedRules)
16
+ addListSection(lines, 'removed rules', diff.removedRules)
17
+
18
+ if (diff.severityChanges.length > 0) {
19
+ lines.push('severity changed:')
20
+ for (const change of diff.severityChanges) {
21
+ lines.push(` - ${change.rule}: ${change.before} -> ${change.after}`)
22
+ }
23
+ }
24
+
25
+ const optionChanges = getDisplayOptionChanges(diff)
26
+ if (optionChanges.length > 0) {
27
+ lines.push('options changed:')
28
+ for (const change of optionChanges) {
29
+ lines.push(` - ${change.rule}: ${formatValue(change.before)} -> ${formatValue(change.after)}`)
30
+ }
31
+ }
32
+
33
+ addListSection(lines, 'workspaces added', diff.workspaceMembershipChanges.added)
34
+ addListSection(lines, 'workspaces removed', diff.workspaceMembershipChanges.removed)
35
+
36
+ return lines.join('\n')
37
+ }
38
+
39
+ export function getDisplayOptionChanges(diff: SnapshotDiff): SnapshotDiff['optionChanges'] {
40
+ const removedRules = new Set(diff.removedRules)
41
+ const severityChangedRules = new Set(diff.severityChanges.map((change) => change.rule))
42
+ return diff.optionChanges.filter((change) => !removedRules.has(change.rule) && !severityChangedRules.has(change.rule))
43
+ }
44
+
45
+ function addListSection(lines: string[], title: string, values: string[]): void {
46
+ if (values.length === 0) {
47
+ return
48
+ }
49
+
50
+ lines.push(`${title}:`)
51
+ for (const value of values) {
52
+ lines.push(` - ${value}`)
53
+ }
54
+ }
55
+
56
+ function formatValue(value: unknown): string {
57
+ const serialized = JSON.stringify(value)
58
+ return serialized === undefined ? 'undefined' : serialized
59
+ }
60
+
61
+ export function summarizeChanges(changes: Array<{ groupId: string; diff: SnapshotDiff }>) {
62
+ let introduced = 0
63
+ let removed = 0
64
+ let severity = 0
65
+ let options = 0
66
+ let workspace = 0
67
+ for (const change of changes) {
68
+ introduced += change.diff.introducedRules.length
69
+ removed += change.diff.removedRules.length
70
+ severity += change.diff.severityChanges.length
71
+ options += getDisplayOptionChanges(change.diff).length
72
+ workspace += change.diff.workspaceMembershipChanges.added.length + change.diff.workspaceMembershipChanges.removed.length
73
+ }
74
+ return { introduced, removed, severity, options, workspace }
75
+ }
76
+
77
+ export function summarizeSnapshots(snapshots: Map<string, SnapshotLike>) {
78
+ const { rules, error, warn, off } = countRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules))
79
+ return { groups: snapshots.size, rules, error, warn, off }
80
+ }
81
+
82
+ export function countUniqueWorkspaces(snapshots: Map<string, SnapshotLike>): number {
83
+ const workspaces = new Set<string>()
84
+ for (const snapshot of snapshots.values()) {
85
+ for (const workspace of snapshot.workspaces) {
86
+ workspaces.add(workspace)
87
+ }
88
+ }
89
+ return workspaces.size
90
+ }
91
+
92
+ export function decorateDiffLine(
93
+ line: string,
94
+ color: { green: (text: string) => string; red: (text: string) => string; yellow: (text: string) => string }
95
+ ): string {
96
+ if (line.startsWith('introduced rules:') || line.startsWith('workspaces added:')) {
97
+ return color.green(`+ ${line}`)
98
+ }
99
+ if (line.startsWith('removed rules:') || line.startsWith('workspaces removed:')) {
100
+ return color.red(`- ${line}`)
101
+ }
102
+ if (line.startsWith('severity changed:') || line.startsWith('options changed:')) {
103
+ return color.yellow(`~ ${line}`)
104
+ }
105
+ return line
106
+ }
107
+
108
+ export function formatShortPrint(snapshots: SnapshotLike[]): string {
109
+ const lines: string[] = []
110
+ const sorted = [...snapshots].sort((a, b) => a.groupId.localeCompare(b.groupId))
111
+
112
+ for (const snapshot of sorted) {
113
+ const ruleNames = Object.keys(snapshot.rules).sort()
114
+ const severityCounts = { error: 0, warn: 0, off: 0 }
115
+
116
+ for (const name of ruleNames) {
117
+ const severity = getPrimarySeverity(snapshot.rules[name])
118
+ if (severity) {
119
+ severityCounts[severity] += 1
120
+ }
121
+ }
122
+
123
+ lines.push(
124
+ `group: ${snapshot.groupId}`,
125
+ `workspaces (${snapshot.workspaces.length}): ${snapshot.workspaces.length > 0 ? snapshot.workspaces.join(', ') : '(none)'}`,
126
+ `rules (${ruleNames.length}): error ${severityCounts.error}, warn ${severityCounts.warn}, off ${severityCounts.off}`
127
+ )
128
+
129
+ for (const ruleName of ruleNames) {
130
+ const entry = snapshot.rules[ruleName]
131
+ if (!entry) {
132
+ continue
133
+ }
134
+ if (!Array.isArray(entry[0])) {
135
+ const singleEntry = entry as RuleEntry
136
+ const suffix = singleEntry.length > 1 ? ` ${JSON.stringify(singleEntry[1])}` : ''
137
+ lines.push(`${ruleName}: ${singleEntry[0]}${suffix}`)
138
+ continue
139
+ }
140
+
141
+ const variants = entry as RuleEntry[]
142
+ lines.push(`${ruleName}: ${JSON.stringify(variants)}`)
143
+ }
144
+ }
145
+
146
+ return `${lines.join('\n')}\n`
147
+ }
148
+
149
+ export function formatShortConfig(payload: {
150
+ source: string
151
+ workspaceInput: unknown
152
+ workspaces: string[]
153
+ grouping: { mode: string; allowEmptyGroups: boolean; groups: Array<{ name: string; workspaces: string[] }> }
154
+ sampling: unknown
155
+ }): string {
156
+ const lines: string[] = [
157
+ `source: ${payload.source}`,
158
+ `workspaces (${payload.workspaces.length}): ${payload.workspaces.join(', ') || '(none)'}`,
159
+ `grouping mode: ${payload.grouping.mode} (allow empty: ${payload.grouping.allowEmptyGroups})`
160
+ ]
161
+ for (const group of payload.grouping.groups) {
162
+ lines.push(`group ${group.name} (${group.workspaces.length}): ${group.workspaces.join(', ') || '(none)'}`)
163
+ }
164
+ lines.push(`workspaceInput: ${JSON.stringify(payload.workspaceInput)}`, `sampling: ${JSON.stringify(payload.sampling)}`)
165
+ return `${lines.join('\n')}\n`
166
+ }
167
+
168
+ export function formatCommandDisplayLabel(commandLabel: string): string {
169
+ switch (commandLabel) {
170
+ case 'check':
171
+ case 'check:summary': {
172
+ return 'Check drift against baseline (summary)'
173
+ }
174
+ case 'check:diff': {
175
+ return 'Check drift against baseline (detailed diff)'
176
+ }
177
+ case 'check:status': {
178
+ return 'Check drift against baseline (status only)'
179
+ }
180
+ case 'update': {
181
+ return 'Update baseline snapshot'
182
+ }
183
+ case 'print:json': {
184
+ return 'Print aggregated rules (JSON)'
185
+ }
186
+ case 'print:short': {
187
+ return 'Print aggregated rules (short view)'
188
+ }
189
+ case 'config:json': {
190
+ return 'Show effective runtime config (JSON)'
191
+ }
192
+ case 'config:short': {
193
+ return 'Show effective runtime config (short view)'
194
+ }
195
+ case 'init': {
196
+ return 'Initialize local configuration'
197
+ }
198
+ case 'help': {
199
+ return 'Show CLI help'
200
+ }
201
+ default: {
202
+ return commandLabel
203
+ }
204
+ }
205
+ }
206
+
207
+ export function formatStoredSnapshotSummary(storedSnapshots: Map<string, SnapshotLike>): string {
208
+ if (storedSnapshots.size === 0) {
209
+ return 'none'
210
+ }
211
+
212
+ const summary = summarizeSnapshots(storedSnapshots)
213
+ return `${summary.groups} groups, ${summary.rules} rules (severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off)`
214
+ }
215
+
216
+ export function countRuleSeverities(ruleObjects: RuleObject[]) {
217
+ let rules = 0
218
+ let error = 0
219
+ let warn = 0
220
+ let off = 0
221
+
222
+ for (const rulesObject of ruleObjects) {
223
+ for (const entry of Object.values(rulesObject)) {
224
+ rules += 1
225
+ const severity = getPrimarySeverity(entry)
226
+ if (severity === 'error') {
227
+ error += 1
228
+ } else if (severity === 'warn') {
229
+ warn += 1
230
+ } else {
231
+ off += 1
232
+ }
233
+ }
234
+ }
235
+
236
+ return { rules, error, warn, off }
237
+ }
238
+
239
+ function getPrimarySeverity(entry: SnapshotRuleEntry | undefined): 'off' | 'warn' | 'error' | undefined {
240
+ if (!entry) {
241
+ return undefined
242
+ }
243
+
244
+ if (!Array.isArray(entry[0])) {
245
+ return (entry as RuleEntry)[0]
246
+ }
247
+
248
+ const variants = entry as RuleEntry[]
249
+ if (variants.some((variant) => variant[0] === 'error')) {
250
+ return 'error'
251
+ }
252
+ if (variants.some((variant) => variant[0] === 'warn')) {
253
+ return 'warn'
254
+ }
255
+ return 'off'
256
+ }