@eslint-config-snapshot/api 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 +45 -0
- package/dist/index.cjs +434 -91
- package/dist/index.js +434 -91
- package/package.json +2 -1
- package/src/config.ts +32 -8
- package/src/diff.ts +51 -17
- package/src/extract.ts +19 -0
- package/src/index.ts +1 -1
- package/src/sampling.ts +351 -40
- package/src/snapshot.ts +41 -44
- package/test/config.test.ts +8 -4
- package/test/diff.test.ts +49 -1
- package/test/sampling.test.ts +50 -11
- package/test/snapshot.test.ts +31 -4
package/src/sampling.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
import createDebug from 'debug'
|
|
1
2
|
import fg from 'fast-glob'
|
|
2
|
-
import picomatch from 'picomatch'
|
|
3
3
|
|
|
4
4
|
import { normalizePath, sortUnique } from './core.js'
|
|
5
5
|
|
|
6
|
+
const debugSampling = createDebug('eslint-config-snapshot:sampling')
|
|
7
|
+
|
|
6
8
|
export type SamplingConfig = {
|
|
7
9
|
maxFilesPerWorkspace: number
|
|
8
10
|
includeGlobs: string[]
|
|
9
11
|
excludeGlobs: string[]
|
|
10
|
-
|
|
12
|
+
tokenHints?: string[] | string[][]
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export async function sampleWorkspaceFiles(workspaceAbs: string, config: SamplingConfig): Promise<string[]> {
|
|
16
|
+
const startedAt = Date.now()
|
|
14
17
|
const all = await fg(config.includeGlobs, {
|
|
15
18
|
cwd: workspaceAbs,
|
|
16
19
|
ignore: config.excludeGlobs,
|
|
@@ -20,46 +23,43 @@ export async function sampleWorkspaceFiles(workspaceAbs: string, config: Samplin
|
|
|
20
23
|
})
|
|
21
24
|
|
|
22
25
|
const normalized = sortUnique(all.map((entry) => normalizePath(entry)))
|
|
26
|
+
debugSampling('workspace=%s candidates=%d', workspaceAbs, normalized.length)
|
|
23
27
|
if (normalized.length === 0) {
|
|
24
28
|
return []
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
if (normalized.length <= config.maxFilesPerWorkspace) {
|
|
32
|
+
debugSampling('workspace=%s using all files=%d elapsedMs=%d', workspaceAbs, normalized.length, Date.now() - startedAt)
|
|
28
33
|
return normalized
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const selected = selectDistributed(normalized, config.maxFilesPerWorkspace, config.tokenHints)
|
|
37
|
+
debugSampling(
|
|
38
|
+
'workspace=%s selected=%d mode=token-distributed elapsedMs=%d files=%o',
|
|
39
|
+
workspaceAbs,
|
|
40
|
+
selected.length,
|
|
41
|
+
Date.now() - startedAt,
|
|
42
|
+
selected
|
|
43
|
+
)
|
|
44
|
+
return selected
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
function selectDistributed(files: string[], count: number): string[] {
|
|
47
|
+
function selectDistributed(files: string[], count: number, tokenHints?: string[] | string[][]): string[] {
|
|
42
48
|
if (files.length <= count) {
|
|
43
49
|
return files
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
const tokenPriorityMap = createTokenPriorityMap(tokenHints)
|
|
46
53
|
const selected: string[] = []
|
|
47
54
|
const selectedSet = new Set<string>()
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!token || tokenSeen.has(token)) {
|
|
57
|
-
continue
|
|
58
|
-
}
|
|
59
|
-
tokenSeen.add(token)
|
|
60
|
-
selected.push(file)
|
|
61
|
-
selectedSet.add(file)
|
|
62
|
-
}
|
|
56
|
+
const preferred = files.filter((file) => isPreferredForLintSampling(file))
|
|
57
|
+
const nonPreferred = files.filter((file) => !isPreferredForLintSampling(file))
|
|
58
|
+
|
|
59
|
+
// First pass: pick token-diverse representatives from code files.
|
|
60
|
+
// Second pass: include non-code only when needed to fill remaining slots.
|
|
61
|
+
appendTokenRepresentatives(preferred, tokenPriorityMap, selected, selectedSet, count)
|
|
62
|
+
appendTokenRepresentatives(nonPreferred, tokenPriorityMap, selected, selectedSet, count)
|
|
63
63
|
|
|
64
64
|
if (selected.length >= count) {
|
|
65
65
|
return sortUnique(selected).slice(0, count)
|
|
@@ -67,8 +67,13 @@ function selectDistributed(files: string[], count: number): string[] {
|
|
|
67
67
|
|
|
68
68
|
const remaining = files.filter((file) => !selectedSet.has(file))
|
|
69
69
|
const needed = count - selected.length
|
|
70
|
-
const
|
|
71
|
-
|
|
70
|
+
const preferredRemaining = remaining.filter((file) => isPreferredForLintSampling(file))
|
|
71
|
+
const nonPreferredRemaining = remaining.filter((file) => !isPreferredForLintSampling(file))
|
|
72
|
+
|
|
73
|
+
const preferredPicked = pickUniformly(preferredRemaining, needed)
|
|
74
|
+
const afterPreferredNeed = needed - preferredPicked.length
|
|
75
|
+
const fallbackPicked = afterPreferredNeed > 0 ? pickUniformly(nonPreferredRemaining, afterPreferredNeed) : []
|
|
76
|
+
return sortUnique([...selected, ...preferredPicked, ...fallbackPicked]).slice(0, count)
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
function pickUniformly(files: string[], count: number): string[] {
|
|
@@ -85,16 +90,67 @@ function pickUniformly(files: string[], count: number): string[] {
|
|
|
85
90
|
const picked: string[] = []
|
|
86
91
|
const usedIndices = new Set<number>()
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
93
|
+
// Ensure regional coverage when possible: top, middle, bottom.
|
|
94
|
+
if (count >= 3) {
|
|
95
|
+
const anchorIndices = [0, Math.floor((files.length - 1) / 2), files.length - 1]
|
|
96
|
+
for (const anchorIndex of anchorIndices) {
|
|
97
|
+
if (picked.length >= count || usedIndices.has(anchorIndex)) {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
usedIndices.add(anchorIndex)
|
|
101
|
+
const anchored = files[anchorIndex]
|
|
102
|
+
if (anchored !== undefined) {
|
|
103
|
+
picked.push(anchored)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const candidate of buildDistributedCandidates(files.length, count)) {
|
|
109
|
+
if (picked.length >= count) {
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
const safeIndex = nextFreeIndex(candidate, usedIndices, files.length)
|
|
113
|
+
if (usedIndices.has(safeIndex)) {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
91
116
|
usedIndices.add(safeIndex)
|
|
92
|
-
|
|
117
|
+
const selected = files[safeIndex]
|
|
118
|
+
if (selected !== undefined) {
|
|
119
|
+
picked.push(selected)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (picked.length < count) {
|
|
124
|
+
for (let index = 0; index < files.length && picked.length < count; index += 1) {
|
|
125
|
+
if (usedIndices.has(index)) {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
usedIndices.add(index)
|
|
129
|
+
const fallback = files[index]
|
|
130
|
+
if (fallback !== undefined) {
|
|
131
|
+
picked.push(fallback)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
93
134
|
}
|
|
94
135
|
|
|
95
136
|
return picked
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
function buildDistributedCandidates(length: number, count: number): number[] {
|
|
140
|
+
if (length <= 0 || count <= 0) {
|
|
141
|
+
return []
|
|
142
|
+
}
|
|
143
|
+
if (count === 1) {
|
|
144
|
+
return [0]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const candidates: number[] = []
|
|
148
|
+
for (let index = 0; index < count; index += 1) {
|
|
149
|
+
candidates.push(Math.round((index * (length - 1)) / (count - 1)))
|
|
150
|
+
}
|
|
151
|
+
return candidates
|
|
152
|
+
}
|
|
153
|
+
|
|
98
154
|
function nextFreeIndex(candidate: number, used: Set<number>, max: number): number {
|
|
99
155
|
if (!used.has(candidate)) {
|
|
100
156
|
return candidate
|
|
@@ -114,23 +170,278 @@ function nextFreeIndex(candidate: number, used: Set<number>, max: number): numbe
|
|
|
114
170
|
return candidate
|
|
115
171
|
}
|
|
116
172
|
|
|
117
|
-
function getPrimaryToken(file: string): string | null {
|
|
118
|
-
const parts = file.split('/')
|
|
119
|
-
|
|
120
|
-
|
|
173
|
+
function getPrimaryToken(file: string, tokenPriorityMap: Map<string, number>): string | null {
|
|
174
|
+
const parts = file.split('/').filter((entry) => entry.length > 0)
|
|
175
|
+
if (parts.length === 0) {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const basename = parts[parts.length - 1]
|
|
180
|
+
if (basename === undefined) {
|
|
121
181
|
return null
|
|
122
182
|
}
|
|
123
|
-
const
|
|
124
|
-
const
|
|
183
|
+
const basenameTokens = tokenizePathPart(basename, true)
|
|
184
|
+
const directoryTokensForward = parts.slice(0, -1).flatMap((entry) => tokenizePathPart(entry, false))
|
|
185
|
+
const directoryTokens: string[] = []
|
|
186
|
+
for (let index = directoryTokensForward.length - 1; index >= 0; index -= 1) {
|
|
187
|
+
const token = directoryTokensForward[index]
|
|
188
|
+
if (token !== undefined) {
|
|
189
|
+
directoryTokens.push(token)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const allTokens = [...basenameTokens, ...directoryTokens].filter((entry) => entry.length > 1)
|
|
193
|
+
|
|
194
|
+
const bestKnownToken = pickBestKnownToken(allTokens, tokenPriorityMap)
|
|
195
|
+
if (bestKnownToken !== null) {
|
|
196
|
+
return bestKnownToken
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const fallback = allTokens.find((entry) => !GENERIC_TOKENS.has(entry))
|
|
200
|
+
return fallback ?? null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function tokenizePathPart(part: string, stripExtension: boolean): string[] {
|
|
204
|
+
const normalized = stripExtension ? part.replace(/\.[^.]+$/u, '') : part
|
|
205
|
+
const expanded = normalized
|
|
125
206
|
.replaceAll(/([a-z])([A-Z])/gu, '$1 $2')
|
|
126
207
|
.replaceAll(/[_\-.]+/gu, ' ')
|
|
127
208
|
.toLowerCase()
|
|
128
209
|
|
|
129
|
-
|
|
210
|
+
return expanded
|
|
130
211
|
.split(/\s+/u)
|
|
131
|
-
.
|
|
212
|
+
.filter((entry) => entry.length > 0)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pickBestKnownToken(tokens: string[], tokenPriorityMap: Map<string, number>): string | null {
|
|
216
|
+
let bestToken: string | null = null
|
|
217
|
+
let bestGroupPriority = Number.POSITIVE_INFINITY
|
|
132
218
|
|
|
133
|
-
|
|
219
|
+
for (const token of tokens) {
|
|
220
|
+
const normalizedToken = normalizeToken(token)
|
|
221
|
+
const groupPriority = tokenPriorityMap.get(normalizedToken)
|
|
222
|
+
if (groupPriority === undefined) {
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (groupPriority < bestGroupPriority) {
|
|
226
|
+
bestGroupPriority = groupPriority
|
|
227
|
+
bestToken = normalizedToken
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return bestToken
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeToken(token: string): string {
|
|
235
|
+
if (token.endsWith('ies') && token.length > 3) {
|
|
236
|
+
return `${token.slice(0, -3)}y`
|
|
237
|
+
}
|
|
238
|
+
if (token.endsWith('s') && token.length > 3) {
|
|
239
|
+
return token.slice(0, -1)
|
|
240
|
+
}
|
|
241
|
+
return token
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isPreferredForLintSampling(file: string): boolean {
|
|
245
|
+
return CODE_PREFERRED_EXTENSIONS.has(getExtension(file))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function getExtension(file: string): string {
|
|
249
|
+
const lastDot = file.lastIndexOf('.')
|
|
250
|
+
if (lastDot === -1 || lastDot === file.length - 1) {
|
|
251
|
+
return ''
|
|
252
|
+
}
|
|
253
|
+
return file.slice(lastDot + 1).toLowerCase()
|
|
134
254
|
}
|
|
135
255
|
|
|
136
256
|
const GENERIC_TOKENS = new Set(['src', 'index', 'main', 'test', 'spec', 'package', 'packages', 'lib', 'dist'])
|
|
257
|
+
const CODE_PREFERRED_EXTENSIONS = new Set(['ts', 'tsx', 'js', 'jsx', 'cjs', 'mjs'])
|
|
258
|
+
|
|
259
|
+
const DEFAULT_TOKEN_HINT_GROUPS = [
|
|
260
|
+
[
|
|
261
|
+
'chunk',
|
|
262
|
+
'conf',
|
|
263
|
+
'config',
|
|
264
|
+
'container',
|
|
265
|
+
'controller',
|
|
266
|
+
'helpers',
|
|
267
|
+
'mock',
|
|
268
|
+
'mocks',
|
|
269
|
+
'presentation',
|
|
270
|
+
'repository',
|
|
271
|
+
'route',
|
|
272
|
+
'routes',
|
|
273
|
+
'schema',
|
|
274
|
+
'setup',
|
|
275
|
+
'spec',
|
|
276
|
+
'stories',
|
|
277
|
+
'style',
|
|
278
|
+
'styles',
|
|
279
|
+
'test',
|
|
280
|
+
'type',
|
|
281
|
+
'types',
|
|
282
|
+
'utils',
|
|
283
|
+
'view',
|
|
284
|
+
'views'
|
|
285
|
+
],
|
|
286
|
+
[
|
|
287
|
+
'adapter',
|
|
288
|
+
'api',
|
|
289
|
+
'apis',
|
|
290
|
+
'builder',
|
|
291
|
+
'client',
|
|
292
|
+
'component',
|
|
293
|
+
'components',
|
|
294
|
+
'constants',
|
|
295
|
+
'context',
|
|
296
|
+
'core',
|
|
297
|
+
'dto',
|
|
298
|
+
'entity',
|
|
299
|
+
'entry',
|
|
300
|
+
'env',
|
|
301
|
+
'factory',
|
|
302
|
+
'fetcher',
|
|
303
|
+
'handler',
|
|
304
|
+
'hook',
|
|
305
|
+
'hooks',
|
|
306
|
+
'init',
|
|
307
|
+
'integration',
|
|
308
|
+
'interceptor',
|
|
309
|
+
'interface',
|
|
310
|
+
'layout',
|
|
311
|
+
'layouts',
|
|
312
|
+
'listener',
|
|
313
|
+
'logger',
|
|
314
|
+
'manager',
|
|
315
|
+
'mapper',
|
|
316
|
+
'meta',
|
|
317
|
+
'middleware',
|
|
318
|
+
'model',
|
|
319
|
+
'module',
|
|
320
|
+
'normalizer',
|
|
321
|
+
'options',
|
|
322
|
+
'page',
|
|
323
|
+
'pages',
|
|
324
|
+
'parser',
|
|
325
|
+
'plugin',
|
|
326
|
+
'provider',
|
|
327
|
+
'registry',
|
|
328
|
+
'resolver',
|
|
329
|
+
'router',
|
|
330
|
+
'runtime',
|
|
331
|
+
'serializer',
|
|
332
|
+
'server',
|
|
333
|
+
'service',
|
|
334
|
+
'settings',
|
|
335
|
+
'shared',
|
|
336
|
+
'slice',
|
|
337
|
+
'state',
|
|
338
|
+
'store',
|
|
339
|
+
'subscriber',
|
|
340
|
+
'theme',
|
|
341
|
+
'tracker',
|
|
342
|
+
'transform',
|
|
343
|
+
'unit',
|
|
344
|
+
'validator'
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
'base',
|
|
348
|
+
'bundle',
|
|
349
|
+
'common',
|
|
350
|
+
'compiler',
|
|
351
|
+
'contract',
|
|
352
|
+
'definition',
|
|
353
|
+
'definitions',
|
|
354
|
+
'deserializer',
|
|
355
|
+
'event',
|
|
356
|
+
'events',
|
|
357
|
+
'fixture',
|
|
358
|
+
'fixtures',
|
|
359
|
+
'guard',
|
|
360
|
+
'internal',
|
|
361
|
+
'loader',
|
|
362
|
+
'publisher',
|
|
363
|
+
'reducer',
|
|
364
|
+
'stub',
|
|
365
|
+
'stubs',
|
|
366
|
+
'tests',
|
|
367
|
+
'util'
|
|
368
|
+
]
|
|
369
|
+
] as const
|
|
370
|
+
|
|
371
|
+
function createTokenPriorityMap(input?: string[] | string[][]): Map<string, number> {
|
|
372
|
+
const groups = normalizeTokenHintGroups(input)
|
|
373
|
+
const entries: Array<[string, number]> = []
|
|
374
|
+
for (const [index, group] of groups.entries()) {
|
|
375
|
+
entries.push(...toPriorityEntries(group, index + 1))
|
|
376
|
+
}
|
|
377
|
+
return new Map<string, number>(entries)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function normalizeTokenHintGroups(input?: string[] | string[][]): string[][] {
|
|
381
|
+
if (!input || input.length === 0) {
|
|
382
|
+
return DEFAULT_TOKEN_HINT_GROUPS.map((group) => [...group])
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (Array.isArray(input[0])) {
|
|
386
|
+
const nested = input as string[][]
|
|
387
|
+
return nested.map((group) => group.filter((token) => token.trim().length > 0))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const flat = input as string[]
|
|
391
|
+
return [flat.filter((token) => token.trim().length > 0)]
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function toPriorityEntries(tokens: string[], priority: number): Array<[string, number]> {
|
|
395
|
+
return tokens.map((token) => [normalizeToken(token), priority])
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function appendTokenRepresentatives(
|
|
399
|
+
files: string[],
|
|
400
|
+
tokenPriorityMap: Map<string, number>,
|
|
401
|
+
selected: string[],
|
|
402
|
+
selectedSet: Set<string>,
|
|
403
|
+
count: number
|
|
404
|
+
): void {
|
|
405
|
+
if (selected.length >= count || files.length === 0) {
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const tokenToFiles = new Map<string, string[]>()
|
|
410
|
+
const tokenFirstIndex = new Map<string, number>()
|
|
411
|
+
for (const [index, file] of files.entries()) {
|
|
412
|
+
const token = getPrimaryToken(file, tokenPriorityMap)
|
|
413
|
+
if (!token) {
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
tokenFirstIndex.set(token, Math.min(tokenFirstIndex.get(token) ?? Number.POSITIVE_INFINITY, index))
|
|
417
|
+
const current = tokenToFiles.get(token) ?? []
|
|
418
|
+
current.push(file)
|
|
419
|
+
tokenToFiles.set(token, current)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const orderedTokens = [...tokenToFiles.keys()].sort((left, right) => {
|
|
423
|
+
const leftPriority = tokenPriorityMap.get(left) ?? Number.POSITIVE_INFINITY
|
|
424
|
+
const rightPriority = tokenPriorityMap.get(right) ?? Number.POSITIVE_INFINITY
|
|
425
|
+
if (leftPriority !== rightPriority) {
|
|
426
|
+
return leftPriority - rightPriority
|
|
427
|
+
}
|
|
428
|
+
const leftIndex = tokenFirstIndex.get(left) ?? Number.POSITIVE_INFINITY
|
|
429
|
+
const rightIndex = tokenFirstIndex.get(right) ?? Number.POSITIVE_INFINITY
|
|
430
|
+
if (leftIndex !== rightIndex) {
|
|
431
|
+
return leftIndex - rightIndex
|
|
432
|
+
}
|
|
433
|
+
return left.localeCompare(right)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
for (const token of orderedTokens) {
|
|
437
|
+
if (selected.length >= count) {
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
const firstFile = tokenToFiles.get(token)?.[0]
|
|
441
|
+
if (!firstFile || selectedSet.has(firstFile)) {
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
selected.push(firstFile)
|
|
445
|
+
selectedSet.add(firstFile)
|
|
446
|
+
}
|
|
447
|
+
}
|
package/src/snapshot.ts
CHANGED
|
@@ -1,71 +1,54 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { canonicalizeJson,
|
|
4
|
+
import { canonicalizeJson, sortUnique } from './core.js'
|
|
5
5
|
|
|
6
6
|
import type { NormalizedRuleEntry } from './extract.js'
|
|
7
7
|
|
|
8
|
+
export type SnapshotRuleEntry = NormalizedRuleEntry | NormalizedRuleEntry[]
|
|
9
|
+
|
|
8
10
|
export type SnapshotFile = {
|
|
9
11
|
formatVersion: 1
|
|
10
12
|
groupId: string
|
|
11
13
|
workspaces: string[]
|
|
12
|
-
rules: Record<string,
|
|
14
|
+
rules: Record<string, SnapshotRuleEntry>
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export function aggregateRules(ruleMaps: readonly Map<string, NormalizedRuleEntry>[]): Map<string,
|
|
16
|
-
const aggregated = new Map<string, NormalizedRuleEntry
|
|
17
|
+
export function aggregateRules(ruleMaps: readonly Map<string, NormalizedRuleEntry>[]): Map<string, SnapshotRuleEntry> {
|
|
18
|
+
const aggregated = new Map<string, Map<string, NormalizedRuleEntry>>()
|
|
17
19
|
|
|
18
20
|
for (const rules of ruleMaps) {
|
|
19
21
|
for (const [ruleName, nextEntry] of rules.entries()) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (severityCmp > 0) {
|
|
28
|
-
aggregated.set(ruleName, canonicalizeJson(nextEntry))
|
|
29
|
-
continue
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (severityCmp < 0) {
|
|
33
|
-
continue
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const currentOptions = currentEntry.length > 1 ? canonicalizeJson(currentEntry[1]) : undefined
|
|
37
|
-
const nextOptions = nextEntry.length > 1 ? canonicalizeJson(nextEntry[1]) : undefined
|
|
38
|
-
|
|
39
|
-
if (currentOptions === undefined && nextOptions !== undefined) {
|
|
40
|
-
aggregated.set(ruleName, canonicalizeJson(nextEntry))
|
|
41
|
-
continue
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (currentOptions !== undefined && nextOptions === undefined) {
|
|
45
|
-
continue
|
|
46
|
-
}
|
|
22
|
+
const normalizedEntry = canonicalizeJson(nextEntry)
|
|
23
|
+
const variantKey = toVariantKey(normalizedEntry)
|
|
24
|
+
const variants = aggregated.get(ruleName) ?? new Map<string, NormalizedRuleEntry>()
|
|
25
|
+
variants.set(variantKey, normalizedEntry)
|
|
26
|
+
aggregated.set(ruleName, variants)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
|
|
30
|
+
const entries = [...aggregated.entries()]
|
|
31
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
32
|
+
.map<[string, SnapshotRuleEntry]>(([ruleName, variants]) => {
|
|
33
|
+
const sortedVariants = [...variants.values()].sort(compareVariants)
|
|
34
|
+
if (sortedVariants.length === 1) {
|
|
35
|
+
return [ruleName, sortedVariants[0]]
|
|
50
36
|
}
|
|
51
37
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (nextJson < currentJson) {
|
|
55
|
-
aggregated.set(ruleName, canonicalizeJson(nextEntry))
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
38
|
+
return [ruleName, sortedVariants]
|
|
39
|
+
})
|
|
59
40
|
|
|
60
|
-
return new Map(
|
|
41
|
+
return new Map(entries)
|
|
61
42
|
}
|
|
62
43
|
|
|
63
|
-
export function buildSnapshot(groupId: string, workspaces: readonly string[], rules: Map<string,
|
|
44
|
+
export function buildSnapshot(groupId: string, workspaces: readonly string[], rules: Map<string, SnapshotRuleEntry>): SnapshotFile {
|
|
64
45
|
const sortedRules = [...rules.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
65
|
-
const rulesObject: Record<string,
|
|
46
|
+
const rulesObject: Record<string, SnapshotRuleEntry> = {}
|
|
66
47
|
|
|
67
48
|
for (const [name, config] of sortedRules) {
|
|
68
|
-
rulesObject[name] =
|
|
49
|
+
rulesObject[name] = isSingleRuleEntry(config)
|
|
50
|
+
? canonicalizeJson(config)
|
|
51
|
+
: config.map((variant) => canonicalizeJson(variant)).sort(compareVariants)
|
|
69
52
|
}
|
|
70
53
|
|
|
71
54
|
return {
|
|
@@ -89,3 +72,17 @@ export async function readSnapshotFile(fileAbs: string): Promise<SnapshotFile> {
|
|
|
89
72
|
const raw = await readFile(fileAbs, 'utf8')
|
|
90
73
|
return JSON.parse(raw) as SnapshotFile
|
|
91
74
|
}
|
|
75
|
+
|
|
76
|
+
function toVariantKey(entry: NormalizedRuleEntry): string {
|
|
77
|
+
return JSON.stringify(canonicalizeJson(entry))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compareVariants(a: NormalizedRuleEntry, b: NormalizedRuleEntry): number {
|
|
81
|
+
const aJson = JSON.stringify(canonicalizeJson(a))
|
|
82
|
+
const bJson = JSON.stringify(canonicalizeJson(b))
|
|
83
|
+
return aJson.localeCompare(bJson)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isSingleRuleEntry(entry: SnapshotRuleEntry): entry is NormalizedRuleEntry {
|
|
87
|
+
return !Array.isArray(entry[0])
|
|
88
|
+
}
|
package/test/config.test.ts
CHANGED
|
@@ -15,6 +15,10 @@ afterEach(async () => {
|
|
|
15
15
|
})
|
|
16
16
|
|
|
17
17
|
describe('loadConfig', () => {
|
|
18
|
+
it('includes markdown files in default sampling globs', () => {
|
|
19
|
+
expect(DEFAULT_CONFIG.sampling.includeGlobs).toContain('**/*.{js,jsx,ts,tsx,cjs,mjs,md,mdx,json,css}')
|
|
20
|
+
})
|
|
21
|
+
|
|
18
22
|
it('returns defaults when no config is found', async () => {
|
|
19
23
|
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
20
24
|
const config = await loadConfig(tmp)
|
|
@@ -69,24 +73,24 @@ describe('loadConfig', () => {
|
|
|
69
73
|
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
70
74
|
await writeFile(
|
|
71
75
|
path.join(tmp, '.eslint-config-snapshot.js'),
|
|
72
|
-
'export default () => ({ grouping: { mode: "standalone" }, sampling: {
|
|
76
|
+
'export default () => ({ grouping: { mode: "standalone" }, sampling: { includeGlobs: ["src/**"] } })\n'
|
|
73
77
|
)
|
|
74
78
|
|
|
75
79
|
const config = await loadConfig(tmp)
|
|
76
80
|
expect(config.grouping.mode).toBe('standalone')
|
|
77
|
-
expect(config.sampling.
|
|
81
|
+
expect(config.sampling.includeGlobs).toEqual(['src/**'])
|
|
78
82
|
})
|
|
79
83
|
|
|
80
84
|
it('executes async function exports', async () => {
|
|
81
85
|
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
82
86
|
await writeFile(
|
|
83
87
|
path.join(tmp, '.eslint-config-snapshot.mjs'),
|
|
84
|
-
'export default async () => ({ sampling: { maxFilesPerWorkspace: 3,
|
|
88
|
+
'export default async () => ({ sampling: { maxFilesPerWorkspace: 3, includeGlobs: ["src/**"] } })\n'
|
|
85
89
|
)
|
|
86
90
|
|
|
87
91
|
const config = await loadConfig(tmp)
|
|
88
92
|
expect(config.sampling.maxFilesPerWorkspace).toBe(3)
|
|
89
|
-
expect(config.sampling.
|
|
93
|
+
expect(config.sampling.includeGlobs).toEqual(['src/**'])
|
|
90
94
|
})
|
|
91
95
|
|
|
92
96
|
it('throws deterministic error when config export is invalid', async () => {
|
package/test/diff.test.ts
CHANGED
|
@@ -55,6 +55,54 @@ describe('diffSnapshots', () => {
|
|
|
55
55
|
|
|
56
56
|
const diff = diffSnapshots(before, after)
|
|
57
57
|
expect(diff.removedRules).toContain('offRule')
|
|
58
|
-
expect(diff.optionChanges).toEqual([
|
|
58
|
+
expect(diff.optionChanges).toEqual([
|
|
59
|
+
{
|
|
60
|
+
rule: 'configured',
|
|
61
|
+
before: [['error', { allow: false }]],
|
|
62
|
+
after: [['error', { allow: true }]]
|
|
63
|
+
}
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('tracks severity-set and variant changes for multi-variant rules', () => {
|
|
68
|
+
const before = {
|
|
69
|
+
formatVersion: 1 as const,
|
|
70
|
+
groupId: 'default',
|
|
71
|
+
workspaces: ['packages/a'],
|
|
72
|
+
rules: {
|
|
73
|
+
mixed: [
|
|
74
|
+
['error', { mode: 'strict' }],
|
|
75
|
+
['warn', { mode: 'legacy' }]
|
|
76
|
+
] as const
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const after = {
|
|
81
|
+
formatVersion: 1 as const,
|
|
82
|
+
groupId: 'default',
|
|
83
|
+
workspaces: ['packages/a'],
|
|
84
|
+
rules: {
|
|
85
|
+
mixed: [
|
|
86
|
+
['error', { mode: 'strict' }],
|
|
87
|
+
['off']
|
|
88
|
+
] as const
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const diff = diffSnapshots(before, after)
|
|
93
|
+
expect(diff.severityChanges).toEqual([{ rule: 'mixed', before: 'error|warn', after: 'error|off' }])
|
|
94
|
+
expect(diff.optionChanges).toEqual([
|
|
95
|
+
{
|
|
96
|
+
rule: 'mixed',
|
|
97
|
+
before: [
|
|
98
|
+
['error', { mode: 'strict' }],
|
|
99
|
+
['warn', { mode: 'legacy' }]
|
|
100
|
+
],
|
|
101
|
+
after: [
|
|
102
|
+
['error', { mode: 'strict' }],
|
|
103
|
+
['off']
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
])
|
|
59
107
|
})
|
|
60
108
|
})
|