@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/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
- hintGlobs: string[]
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
- if (config.hintGlobs.length === 0) {
32
- return selectDistributed(normalized, config.maxFilesPerWorkspace)
33
- }
34
-
35
- const hinted = normalized.filter((entry) => config.hintGlobs.some((pattern) => picomatch(pattern, { dot: true })(entry)))
36
- const notHinted = normalized.filter((entry) => !hinted.includes(entry))
37
-
38
- return selectDistributed([...hinted, ...notHinted], config.maxFilesPerWorkspace)
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
- // First pass: attempt token diversity using simple filename tokenization.
50
- const tokenSeen = new Set<string>()
51
- for (const file of files) {
52
- if (selected.length >= count) {
53
- break
54
- }
55
- const token = getPrimaryToken(file)
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 spaced = pickUniformly(remaining, needed)
71
- return sortUnique([...selected, ...spaced]).slice(0, count)
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
- for (let index = 0; index < count; index += 1) {
89
- const raw = Math.round((index * (files.length - 1)) / (count - 1))
90
- const safeIndex = nextFreeIndex(raw, usedIndices, files.length)
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
- picked.push(files[safeIndex])
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
- const basename = parts.slice(-1)[0]
120
- if (!basename) {
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 nameOnly = basename.replace(/\.[^.]+$/u, '')
124
- const expanded = nameOnly
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
- const token = expanded
210
+ return expanded
130
211
  .split(/\s+/u)
131
- .find((entry) => entry.length > 1 && !GENERIC_TOKENS.has(entry))
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
- return token ?? null
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, compareSeverity, sortUnique } from './core.js'
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, NormalizedRuleEntry>
14
+ rules: Record<string, SnapshotRuleEntry>
13
15
  }
14
16
 
15
- export function aggregateRules(ruleMaps: readonly Map<string, NormalizedRuleEntry>[]): Map<string, NormalizedRuleEntry> {
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 currentEntry = aggregated.get(ruleName)
21
- if (!currentEntry) {
22
- aggregated.set(ruleName, canonicalizeJson(nextEntry))
23
- continue
24
- }
25
-
26
- const severityCmp = compareSeverity(nextEntry[0], currentEntry[0])
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
- if (currentOptions === undefined && nextOptions === undefined) {
49
- continue
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
- const currentJson = JSON.stringify(currentOptions)
53
- const nextJson = JSON.stringify(nextOptions)
54
- if (nextJson < currentJson) {
55
- aggregated.set(ruleName, canonicalizeJson(nextEntry))
56
- }
57
- }
58
- }
38
+ return [ruleName, sortedVariants]
39
+ })
59
40
 
60
- return new Map([...aggregated.entries()].sort(([a], [b]) => a.localeCompare(b)))
41
+ return new Map(entries)
61
42
  }
62
43
 
63
- export function buildSnapshot(groupId: string, workspaces: readonly string[], rules: Map<string, NormalizedRuleEntry>): SnapshotFile {
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, NormalizedRuleEntry> = {}
46
+ const rulesObject: Record<string, SnapshotRuleEntry> = {}
66
47
 
67
48
  for (const [name, config] of sortedRules) {
68
- rulesObject[name] = canonicalizeJson(config)
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
+ }
@@ -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: { hintGlobs: ["src/**"] } })\n'
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.hintGlobs).toEqual(['src/**'])
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, hintGlobs: ["src/**"] } })\n'
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.hintGlobs).toEqual(['src/**'])
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([{ rule: 'configured', before: { allow: false }, after: { allow: true } }])
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
  })