@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/CHANGELOG.md +74 -0
- package/README.md +10 -0
- package/dist/index.cjs +975 -794
- package/dist/index.js +978 -800
- package/package.json +3 -2
- package/src/commands/check.ts +157 -0
- package/src/commands/print.ts +58 -0
- package/src/commands/update.ts +49 -0
- package/src/formatters.ts +256 -0
- package/src/index.ts +48 -1204
- package/src/init.ts +331 -0
- package/src/run-context.ts +161 -0
- package/src/runtime.ts +224 -0
- package/src/terminal.ts +178 -0
- package/test/cli.integration.test.ts +4 -6
- package/test/cli.npm-isolated.integration.test.ts +1 -1
- package/test/cli.pnpm-isolated.integration.test.ts +1 -1
- package/test/cli.terminal.integration.test.ts +10 -5
- package/test/fixtures/npm-isolated-template/eslint-config-snapshot.config.mjs +1 -2
- package/test/fixtures/repo/eslint-config-snapshot.config.mjs +1 -2
- package/test/formatters.unit.test.ts +47 -0
- package/test/init.unit.test.ts +31 -0
- package/test/runtime.unit.test.ts +36 -0
- package/test/ui.unit.test.ts +12 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aggregateRules,
|
|
3
|
+
assignGroupsByMatch,
|
|
4
|
+
buildSnapshot,
|
|
5
|
+
diffSnapshots,
|
|
6
|
+
discoverWorkspaces,
|
|
7
|
+
extractRulesForWorkspaceSamples,
|
|
8
|
+
type GroupAssignment,
|
|
9
|
+
hasDiff,
|
|
10
|
+
loadConfig,
|
|
11
|
+
readSnapshotFile,
|
|
12
|
+
resolveEslintVersionForWorkspace,
|
|
13
|
+
sampleWorkspaceFiles,
|
|
14
|
+
type SnapshotConfig,
|
|
15
|
+
type WorkspaceDiscovery,
|
|
16
|
+
writeSnapshotFile
|
|
17
|
+
} from '@eslint-config-snapshot/api'
|
|
18
|
+
import createDebug from 'debug'
|
|
19
|
+
import fg from 'fast-glob'
|
|
20
|
+
import { mkdir } from 'node:fs/promises'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
const debugWorkspace = createDebug('eslint-config-snapshot:workspace')
|
|
24
|
+
const debugDiff = createDebug('eslint-config-snapshot:diff')
|
|
25
|
+
const debugTiming = createDebug('eslint-config-snapshot:timing')
|
|
26
|
+
|
|
27
|
+
export type BuiltSnapshot = Awaited<ReturnType<typeof buildSnapshot>>
|
|
28
|
+
export type StoredSnapshot = Awaited<ReturnType<typeof readSnapshotFile>>
|
|
29
|
+
export type SnapshotDiff = ReturnType<typeof diffSnapshots>
|
|
30
|
+
export type GroupEslintVersions = Map<string, string[]>
|
|
31
|
+
export type WorkspaceAssignments = {
|
|
32
|
+
discovery: WorkspaceDiscovery
|
|
33
|
+
assignments: GroupAssignment[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function computeCurrentSnapshots(cwd: string): Promise<Map<string, BuiltSnapshot>> {
|
|
37
|
+
const computeStartedAt = Date.now()
|
|
38
|
+
const configStartedAt = Date.now()
|
|
39
|
+
const config = await loadConfig(cwd)
|
|
40
|
+
debugTiming('phase=loadConfig elapsedMs=%d', Date.now() - configStartedAt)
|
|
41
|
+
|
|
42
|
+
const assignmentStartedAt = Date.now()
|
|
43
|
+
const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config)
|
|
44
|
+
debugTiming('phase=resolveWorkspaceAssignments elapsedMs=%d', Date.now() - assignmentStartedAt)
|
|
45
|
+
debugWorkspace('root=%s groups=%d workspaces=%d', discovery.rootAbs, assignments.length, discovery.workspacesRel.length)
|
|
46
|
+
|
|
47
|
+
const snapshots = new Map<string, BuiltSnapshot>()
|
|
48
|
+
|
|
49
|
+
for (const group of assignments) {
|
|
50
|
+
const groupStartedAt = Date.now()
|
|
51
|
+
const extractedForGroup = []
|
|
52
|
+
debugWorkspace('group=%s workspaces=%o', group.name, group.workspaces)
|
|
53
|
+
|
|
54
|
+
for (const workspaceRel of group.workspaces) {
|
|
55
|
+
const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
|
|
56
|
+
const sampleStartedAt = Date.now()
|
|
57
|
+
const sampled = await sampleWorkspaceFiles(workspaceAbs, config.sampling)
|
|
58
|
+
debugWorkspace(
|
|
59
|
+
'group=%s workspace=%s sampled=%d sampleElapsedMs=%d files=%o',
|
|
60
|
+
group.name,
|
|
61
|
+
workspaceRel,
|
|
62
|
+
sampled.length,
|
|
63
|
+
Date.now() - sampleStartedAt,
|
|
64
|
+
sampled
|
|
65
|
+
)
|
|
66
|
+
let extractedCount = 0
|
|
67
|
+
let lastExtractionError: string | undefined
|
|
68
|
+
|
|
69
|
+
const sampledAbs = sampled.map((sampledRel) => path.resolve(workspaceAbs, sampledRel))
|
|
70
|
+
const extractStartedAt = Date.now()
|
|
71
|
+
const results = await extractRulesForWorkspaceSamples(workspaceAbs, sampledAbs)
|
|
72
|
+
debugTiming(
|
|
73
|
+
'phase=extract group=%s workspace=%s sampled=%d elapsedMs=%d',
|
|
74
|
+
group.name,
|
|
75
|
+
workspaceRel,
|
|
76
|
+
sampledAbs.length,
|
|
77
|
+
Date.now() - extractStartedAt
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
for (const result of results) {
|
|
81
|
+
if (result.rules) {
|
|
82
|
+
extractedForGroup.push(result.rules)
|
|
83
|
+
extractedCount += 1
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
88
|
+
if (isRecoverableExtractionError(message)) {
|
|
89
|
+
lastExtractionError = message
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw result.error ?? new Error(message)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (extractedCount === 0) {
|
|
97
|
+
const context = lastExtractionError ? ` Last error: ${lastExtractionError}` : ''
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Unable to extract ESLint config for workspace ${workspaceRel}. All sampled files were ignored or produced non-JSON output.${context}`
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
debugWorkspace(
|
|
104
|
+
'group=%s workspace=%s extracted=%d failed=%d',
|
|
105
|
+
group.name,
|
|
106
|
+
workspaceRel,
|
|
107
|
+
extractedCount,
|
|
108
|
+
results.length - extractedCount
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const aggregated = aggregateRules(extractedForGroup)
|
|
113
|
+
snapshots.set(group.name, buildSnapshot(group.name, group.workspaces, aggregated))
|
|
114
|
+
debugWorkspace(
|
|
115
|
+
'group=%s aggregatedRules=%d groupElapsedMs=%d',
|
|
116
|
+
group.name,
|
|
117
|
+
aggregated.size,
|
|
118
|
+
Date.now() - groupStartedAt
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
debugTiming('phase=computeCurrentSnapshots elapsedMs=%d', Date.now() - computeStartedAt)
|
|
123
|
+
return snapshots
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isRecoverableExtractionError(message: string): boolean {
|
|
127
|
+
return (
|
|
128
|
+
message.startsWith('Invalid JSON from eslint --print-config') ||
|
|
129
|
+
message.startsWith('Empty ESLint print-config output') ||
|
|
130
|
+
message.includes('File ignored because of a matching ignore pattern') ||
|
|
131
|
+
message.includes('File ignored by default')
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function resolveWorkspaceAssignments(cwd: string, config: SnapshotConfig): Promise<WorkspaceAssignments> {
|
|
136
|
+
const discovery = await discoverWorkspaces({ cwd, workspaceInput: config.workspaceInput })
|
|
137
|
+
|
|
138
|
+
const assignments =
|
|
139
|
+
config.grouping.mode === 'standalone'
|
|
140
|
+
? discovery.workspacesRel.map((workspace) => ({ name: workspace, workspaces: [workspace] }))
|
|
141
|
+
: assignGroupsByMatch(discovery.workspacesRel, config.grouping.groups ?? [{ name: 'default', match: ['**/*'] }])
|
|
142
|
+
|
|
143
|
+
const allowEmptyGroups = config.grouping.allowEmptyGroups ?? false
|
|
144
|
+
if (!allowEmptyGroups) {
|
|
145
|
+
const empty = assignments.filter((group) => group.workspaces.length === 0)
|
|
146
|
+
if (empty.length > 0) {
|
|
147
|
+
throw new Error(`Empty groups are not allowed: ${empty.map((entry) => entry.name).join(', ')}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { discovery, assignments }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function loadStoredSnapshots(cwd: string, snapshotDir: string): Promise<Map<string, StoredSnapshot>> {
|
|
155
|
+
const dir = path.join(cwd, snapshotDir)
|
|
156
|
+
const files = await fg('**/*.json', { cwd: dir, absolute: true, onlyFiles: true, dot: true, suppressErrors: true })
|
|
157
|
+
const snapshots = new Map<string, StoredSnapshot>()
|
|
158
|
+
const sortedFiles = [...files].sort((a, b) => a.localeCompare(b))
|
|
159
|
+
|
|
160
|
+
for (const file of sortedFiles) {
|
|
161
|
+
const snapshot = await readSnapshotFile(file)
|
|
162
|
+
snapshots.set(snapshot.groupId, snapshot)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return snapshots
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function writeSnapshots(cwd: string, snapshotDir: string, snapshots: Map<string, BuiltSnapshot>): Promise<void> {
|
|
169
|
+
await mkdir(path.join(cwd, snapshotDir), { recursive: true })
|
|
170
|
+
for (const snapshot of snapshots.values()) {
|
|
171
|
+
await writeSnapshotFile(path.join(cwd, snapshotDir), snapshot)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function compareSnapshotMaps(before: Map<string, StoredSnapshot>, after: Map<string, BuiltSnapshot>) {
|
|
176
|
+
const startedAt = Date.now()
|
|
177
|
+
const ids = [...new Set([...before.keys(), ...after.keys()])].sort()
|
|
178
|
+
const changes: Array<{ groupId: string; diff: SnapshotDiff }> = []
|
|
179
|
+
|
|
180
|
+
for (const id of ids) {
|
|
181
|
+
const prev =
|
|
182
|
+
before.get(id) ??
|
|
183
|
+
({
|
|
184
|
+
formatVersion: 1,
|
|
185
|
+
groupId: id,
|
|
186
|
+
workspaces: [],
|
|
187
|
+
rules: {}
|
|
188
|
+
} as const)
|
|
189
|
+
|
|
190
|
+
const next =
|
|
191
|
+
after.get(id) ??
|
|
192
|
+
({
|
|
193
|
+
formatVersion: 1,
|
|
194
|
+
groupId: id,
|
|
195
|
+
workspaces: [],
|
|
196
|
+
rules: {}
|
|
197
|
+
} as const)
|
|
198
|
+
|
|
199
|
+
const diff = diffSnapshots(prev, next)
|
|
200
|
+
if (hasDiff(diff)) {
|
|
201
|
+
changes.push({ groupId: id, diff })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
debugDiff('groupsCompared=%d changedGroups=%d elapsedMs=%d', ids.length, changes.length, Date.now() - startedAt)
|
|
206
|
+
return changes
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function resolveGroupEslintVersions(cwd: string): Promise<GroupEslintVersions> {
|
|
210
|
+
const config = await loadConfig(cwd)
|
|
211
|
+
const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config)
|
|
212
|
+
const result = new Map<string, string[]>()
|
|
213
|
+
|
|
214
|
+
for (const group of assignments) {
|
|
215
|
+
const versions = new Set<string>()
|
|
216
|
+
for (const workspaceRel of group.workspaces) {
|
|
217
|
+
const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
|
|
218
|
+
versions.add(resolveEslintVersionForWorkspace(workspaceAbs))
|
|
219
|
+
}
|
|
220
|
+
result.set(group.name, [...versions].sort((a, b) => a.localeCompare(b)))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return result
|
|
224
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline'
|
|
2
|
+
|
|
3
|
+
type Colorizer = {
|
|
4
|
+
green: (text: string) => string
|
|
5
|
+
yellow: (text: string) => string
|
|
6
|
+
red: (text: string) => string
|
|
7
|
+
bold: (text: string) => string
|
|
8
|
+
dim: (text: string) => string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RunTimer = {
|
|
12
|
+
label: string
|
|
13
|
+
startedAtMs: number
|
|
14
|
+
pausedMs: number
|
|
15
|
+
pauseStartedAtMs: number | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class TerminalIO {
|
|
19
|
+
private readonly color = createColorizer()
|
|
20
|
+
private activeRunTimer: RunTimer | undefined
|
|
21
|
+
|
|
22
|
+
get isTTY(): boolean {
|
|
23
|
+
return process.stdout.isTTY === true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get isInteractive(): boolean {
|
|
27
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get showProgress(): boolean {
|
|
31
|
+
if (process.env.ESLINT_CONFIG_SNAPSHOT_NO_PROGRESS === '1') {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
return this.isTTY
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get colors(): Colorizer {
|
|
38
|
+
return this.color
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
write(text: string): void {
|
|
42
|
+
process.stdout.write(text)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
error(text: string): void {
|
|
46
|
+
process.stderr.write(text)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
subtle(text: string): void {
|
|
50
|
+
this.write(this.color.dim(text))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
section(title: string): void {
|
|
54
|
+
this.write(`${this.color.bold(title)}\n`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
success(text: string): void {
|
|
58
|
+
this.write(this.color.green(text))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
warning(text: string): void {
|
|
62
|
+
this.write(this.color.yellow(text))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
danger(text: string): void {
|
|
66
|
+
this.write(this.color.red(text))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
bold(text: string): string {
|
|
70
|
+
return this.color.bold(text)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beginRun(label: string): void {
|
|
74
|
+
if (!this.showProgress) {
|
|
75
|
+
this.activeRunTimer = undefined
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.activeRunTimer = {
|
|
80
|
+
label,
|
|
81
|
+
startedAtMs: Date.now(),
|
|
82
|
+
pausedMs: 0,
|
|
83
|
+
pauseStartedAtMs: undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
endRun(exitCode: number, logTiming: (timer: RunTimer, elapsedMs: number) => void): void {
|
|
88
|
+
if (!this.activeRunTimer || !this.showProgress) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.activeRunTimer.pauseStartedAtMs !== undefined) {
|
|
93
|
+
this.activeRunTimer.pausedMs += Date.now() - this.activeRunTimer.pauseStartedAtMs
|
|
94
|
+
this.activeRunTimer.pauseStartedAtMs = undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const elapsedMs = Math.max(0, Date.now() - this.activeRunTimer.startedAtMs - this.activeRunTimer.pausedMs)
|
|
98
|
+
logTiming(this.activeRunTimer, elapsedMs)
|
|
99
|
+
const seconds = (elapsedMs / 1000).toFixed(2)
|
|
100
|
+
this.subtle(exitCode === 0 ? `⏱️ Finished in ${seconds}s\n` : `⏱️ Finished with errors in ${seconds}s\n`)
|
|
101
|
+
this.activeRunTimer = undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pauseRun(): void {
|
|
105
|
+
if (!this.activeRunTimer || this.activeRunTimer.pauseStartedAtMs !== undefined) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
this.activeRunTimer.pauseStartedAtMs = Date.now()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resumeRun(): void {
|
|
112
|
+
if (!this.activeRunTimer || this.activeRunTimer.pauseStartedAtMs === undefined) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
this.activeRunTimer.pausedMs += Date.now() - this.activeRunTimer.pauseStartedAtMs
|
|
116
|
+
this.activeRunTimer.pauseStartedAtMs = undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async withPausedRunTimer<T>(task: () => Promise<T>): Promise<T> {
|
|
120
|
+
this.pauseRun()
|
|
121
|
+
try {
|
|
122
|
+
return await task()
|
|
123
|
+
} finally {
|
|
124
|
+
this.resumeRun()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async askYesNo(prompt: string, defaultYes: boolean): Promise<boolean> {
|
|
129
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
130
|
+
try {
|
|
131
|
+
const answerRaw = await this.askQuestion(rl, prompt)
|
|
132
|
+
const answer = answerRaw.trim().toLowerCase()
|
|
133
|
+
if (answer.length === 0) {
|
|
134
|
+
return defaultYes
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return answer === 'y' || answer === 'yes'
|
|
138
|
+
} finally {
|
|
139
|
+
rl.close()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async askQuestion(rl: ReturnType<typeof createInterface>, prompt: string): Promise<string> {
|
|
144
|
+
this.pauseRun()
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
rl.question(prompt, (answer) => {
|
|
147
|
+
this.resumeRun()
|
|
148
|
+
resolve(answer)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function resolveInvocationLabel(argv: string[]): string {
|
|
155
|
+
const commandToken = argv.find((entry) => !entry.startsWith('-'))
|
|
156
|
+
if (commandToken) {
|
|
157
|
+
return commandToken
|
|
158
|
+
}
|
|
159
|
+
if (argv.includes('-u') || argv.includes('--update')) {
|
|
160
|
+
return 'update'
|
|
161
|
+
}
|
|
162
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
163
|
+
return 'help'
|
|
164
|
+
}
|
|
165
|
+
return 'check'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createColorizer(): Colorizer {
|
|
169
|
+
const enabled = process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'
|
|
170
|
+
const wrap = (code: string, text: string) => (enabled ? `\u001B[${code}m${text}\u001B[0m` : text)
|
|
171
|
+
return {
|
|
172
|
+
green: (text: string) => wrap('32', text),
|
|
173
|
+
yellow: (text: string) => wrap('33', text),
|
|
174
|
+
red: (text: string) => wrap('31', text),
|
|
175
|
+
bold: (text: string) => wrap('1', text),
|
|
176
|
+
dim: (text: string) => wrap('2', text)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -84,7 +84,7 @@ describe.sequential('cli integration', () => {
|
|
|
84
84
|
workspaces: ['packages/ws-a', 'packages/ws-b'],
|
|
85
85
|
rules: {
|
|
86
86
|
eqeqeq: ['error', 'always'],
|
|
87
|
-
'no-console': ['error'],
|
|
87
|
+
'no-console': [['error'], ['warn']],
|
|
88
88
|
'no-debugger': ['off']
|
|
89
89
|
}
|
|
90
90
|
})
|
|
@@ -130,7 +130,7 @@ describe.sequential('cli integration', () => {
|
|
|
130
130
|
workspaces (2): packages/ws-a, packages/ws-b
|
|
131
131
|
rules (3): error 2, warn 0, off 1
|
|
132
132
|
eqeqeq: error "always"
|
|
133
|
-
no-console: error
|
|
133
|
+
no-console: [["error"],["warn"]]
|
|
134
134
|
no-debugger: off
|
|
135
135
|
`
|
|
136
136
|
)
|
|
@@ -211,8 +211,7 @@ no-debugger: off
|
|
|
211
211
|
sampling: {
|
|
212
212
|
maxFilesPerWorkspace: 8,
|
|
213
213
|
includeGlobs: ['**/*.ts'],
|
|
214
|
-
excludeGlobs: ['**/node_modules/**']
|
|
215
|
-
hintGlobs: []
|
|
214
|
+
excludeGlobs: ['**/node_modules/**']
|
|
216
215
|
}
|
|
217
216
|
}
|
|
218
217
|
`
|
|
@@ -251,8 +250,7 @@ no-debugger: off
|
|
|
251
250
|
sampling: {
|
|
252
251
|
maxFilesPerWorkspace: 8,
|
|
253
252
|
includeGlobs: ['**/*.ts'],
|
|
254
|
-
excludeGlobs: ['**/node_modules/**']
|
|
255
|
-
hintGlobs: []
|
|
253
|
+
excludeGlobs: ['**/node_modules/**']
|
|
256
254
|
}
|
|
257
255
|
}
|
|
258
256
|
`
|
|
@@ -103,7 +103,7 @@ describe('cli npm-isolated integration', () => {
|
|
|
103
103
|
workspaces: ['packages/ws-a', 'packages/ws-b'],
|
|
104
104
|
rules: {
|
|
105
105
|
eqeqeq: ['error', 'always'],
|
|
106
|
-
'no-console': ['error'],
|
|
106
|
+
'no-console': [['error'], ['warn']],
|
|
107
107
|
'no-debugger': ['off']
|
|
108
108
|
}
|
|
109
109
|
})
|
|
@@ -141,7 +141,7 @@ describe('cli pnpm-isolated integration', () => {
|
|
|
141
141
|
workspaces: ['packages/ws-a', 'packages/ws-b'],
|
|
142
142
|
rules: {
|
|
143
143
|
eqeqeq: ['error', 'always'],
|
|
144
|
-
'no-console': ['error'],
|
|
144
|
+
'no-console': [['error'], ['warn']],
|
|
145
145
|
'no-debugger': ['off']
|
|
146
146
|
}
|
|
147
147
|
})
|
|
@@ -132,7 +132,7 @@ describe('cli terminal invocation', () => {
|
|
|
132
132
|
const compare = run(['compare'])
|
|
133
133
|
expect(compare.status).toBe(1)
|
|
134
134
|
expect(compare.stdout).toBe(
|
|
135
|
-
'group: default\noptions changed:\n - eqeqeq: "always" -> "smart"\nTip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n'
|
|
135
|
+
'group: default\noptions changed:\n - eqeqeq: [["error","always"]] -> [["error","smart"]]\nTip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n'
|
|
136
136
|
)
|
|
137
137
|
expect(compare.stderr).toBe('')
|
|
138
138
|
})
|
|
@@ -185,7 +185,12 @@ describe('cli terminal invocation', () => {
|
|
|
185
185
|
"always"
|
|
186
186
|
],
|
|
187
187
|
"no-console": [
|
|
188
|
-
|
|
188
|
+
[
|
|
189
|
+
"error"
|
|
190
|
+
],
|
|
191
|
+
[
|
|
192
|
+
"warn"
|
|
193
|
+
]
|
|
189
194
|
],
|
|
190
195
|
"no-debugger": [
|
|
191
196
|
"off"
|
|
@@ -206,7 +211,7 @@ describe('cli terminal invocation', () => {
|
|
|
206
211
|
workspaces (2): packages/ws-a, packages/ws-b
|
|
207
212
|
rules (3): error 2, warn 0, off 1
|
|
208
213
|
eqeqeq: error "always"
|
|
209
|
-
no-console: error
|
|
214
|
+
no-console: [["error"],["warn"]]
|
|
210
215
|
no-debugger: off
|
|
211
216
|
`
|
|
212
217
|
)
|
|
@@ -337,7 +342,7 @@ no-debugger: off
|
|
|
337
342
|
`export default {
|
|
338
343
|
workspaceInput: { mode: 'manual', workspaces: ['packages/ws-a'] },
|
|
339
344
|
grouping: { mode: 'match', allowEmptyGroups: false, groups: [{ name: 'never', match: ['ops/**'] }] },
|
|
340
|
-
sampling: { maxFilesPerWorkspace: 8, includeGlobs: ['**/*.ts'], excludeGlobs: ['**/node_modules/**']
|
|
345
|
+
sampling: { maxFilesPerWorkspace: 8, includeGlobs: ['**/*.ts'], excludeGlobs: ['**/node_modules/**'] }
|
|
341
346
|
}
|
|
342
347
|
`
|
|
343
348
|
)
|
|
@@ -360,7 +365,7 @@ no-debugger: off
|
|
|
360
365
|
'eslint-config-snapshot': {
|
|
361
366
|
workspaceInput: { mode: 'manual', workspaces: ['packages/ws-a'] },
|
|
362
367
|
grouping: { mode: 'match', groups: [{ name: 'default', match: ['**/*'] }] },
|
|
363
|
-
sampling: { maxFilesPerWorkspace: 8, includeGlobs: ['**/*.ts'], excludeGlobs: ['**/node_modules/**']
|
|
368
|
+
sampling: { maxFilesPerWorkspace: 8, includeGlobs: ['**/*.ts'], excludeGlobs: ['**/node_modules/**'] }
|
|
364
369
|
}
|
|
365
370
|
},
|
|
366
371
|
null,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatCommandDisplayLabel, formatDiff, summarizeSnapshots } from '../src/formatters.js'
|
|
4
|
+
|
|
5
|
+
describe('output helpers', () => {
|
|
6
|
+
it('formats friendly command labels', () => {
|
|
7
|
+
expect(formatCommandDisplayLabel('check')).toBe('Check drift against baseline (summary)')
|
|
8
|
+
expect(formatCommandDisplayLabel('print:short')).toBe('Print aggregated rules (short view)')
|
|
9
|
+
expect(formatCommandDisplayLabel('custom')).toBe('custom')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('formats nested diff sections', () => {
|
|
13
|
+
const formatted = formatDiff('default', {
|
|
14
|
+
introducedRules: ['a'],
|
|
15
|
+
removedRules: ['b'],
|
|
16
|
+
severityChanges: [{ rule: 'c', before: 'error', after: 'off' }],
|
|
17
|
+
optionChanges: [{ rule: 'd', before: { a: true }, after: { a: false } }],
|
|
18
|
+
workspaceMembershipChanges: { added: ['packages/new'], removed: ['packages/old'] }
|
|
19
|
+
})
|
|
20
|
+
expect(formatted).toContain('group: default')
|
|
21
|
+
expect(formatted).toContain('introduced rules:')
|
|
22
|
+
expect(formatted).toContain('removed rules:')
|
|
23
|
+
expect(formatted).toContain('severity changed:')
|
|
24
|
+
expect(formatted).toContain('options changed:')
|
|
25
|
+
expect(formatted).toContain('workspaces added:')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('summarizes snapshot severities', () => {
|
|
29
|
+
const summary = summarizeSnapshots(
|
|
30
|
+
new Map([
|
|
31
|
+
[
|
|
32
|
+
'default',
|
|
33
|
+
{
|
|
34
|
+
groupId: 'default',
|
|
35
|
+
workspaces: ['packages/a'],
|
|
36
|
+
rules: {
|
|
37
|
+
a: ['error'],
|
|
38
|
+
b: ['warn'],
|
|
39
|
+
c: ['off']
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
])
|
|
44
|
+
)
|
|
45
|
+
expect(summary).toEqual({ groups: 1, rules: 3, error: 1, warn: 1, off: 1 })
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { buildRecommendedConfigFromAssignments } from '../src/init.js'
|
|
4
|
+
|
|
5
|
+
describe('init module', () => {
|
|
6
|
+
it('returns empty config when no explicit assignments are provided', () => {
|
|
7
|
+
const config = buildRecommendedConfigFromAssignments(['packages/a', 'packages/b'], new Map())
|
|
8
|
+
expect(config).toEqual({})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('builds static groups followed by dynamic catch-all', () => {
|
|
12
|
+
const config = buildRecommendedConfigFromAssignments(
|
|
13
|
+
['packages/a', 'packages/b', 'packages/c'],
|
|
14
|
+
new Map([
|
|
15
|
+
['packages/b', 2],
|
|
16
|
+
['packages/c', 1]
|
|
17
|
+
])
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(config).toEqual({
|
|
21
|
+
grouping: {
|
|
22
|
+
mode: 'match',
|
|
23
|
+
groups: [
|
|
24
|
+
{ name: 'group-1', match: ['packages/c'] },
|
|
25
|
+
{ name: 'group-2', match: ['packages/b'] },
|
|
26
|
+
{ name: 'default', match: ['**/*'] }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { compareSnapshotMaps } from '../src/runtime.js'
|
|
4
|
+
|
|
5
|
+
describe('runtime helpers', () => {
|
|
6
|
+
it('detects changed groups deterministically', () => {
|
|
7
|
+
const before = new Map([
|
|
8
|
+
[
|
|
9
|
+
'default',
|
|
10
|
+
{
|
|
11
|
+
formatVersion: 1 as const,
|
|
12
|
+
groupId: 'default',
|
|
13
|
+
workspaces: ['packages/a'],
|
|
14
|
+
rules: { a: ['error'] as const }
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
const after = new Map([
|
|
20
|
+
[
|
|
21
|
+
'default',
|
|
22
|
+
{
|
|
23
|
+
formatVersion: 1 as const,
|
|
24
|
+
groupId: 'default',
|
|
25
|
+
workspaces: ['packages/a'],
|
|
26
|
+
rules: { a: ['off'] as const }
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
const changes = compareSnapshotMaps(before, after)
|
|
32
|
+
expect(changes).toHaveLength(1)
|
|
33
|
+
expect(changes[0]?.groupId).toBe('default')
|
|
34
|
+
expect(changes[0]?.diff.severityChanges).toHaveLength(1)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { resolveInvocationLabel } from '../src/terminal.js'
|
|
4
|
+
|
|
5
|
+
describe('terminal module', () => {
|
|
6
|
+
it('resolves command labels deterministically', () => {
|
|
7
|
+
expect(resolveInvocationLabel(['check'])).toBe('check')
|
|
8
|
+
expect(resolveInvocationLabel(['--update'])).toBe('update')
|
|
9
|
+
expect(resolveInvocationLabel(['--help'])).toBe('help')
|
|
10
|
+
expect(resolveInvocationLabel([])).toBe('check')
|
|
11
|
+
})
|
|
12
|
+
})
|