@eslint-config-snapshot/cli 0.8.0 → 0.14.0

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/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
+ }
@@ -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
- "error"
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/**'], hintGlobs: [] }
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/**'], hintGlobs: [] }
368
+ sampling: { maxFilesPerWorkspace: 8, includeGlobs: ['**/*.ts'], excludeGlobs: ['**/node_modules/**'] }
364
369
  }
365
370
  },
366
371
  null,
@@ -10,7 +10,6 @@ export default {
10
10
  sampling: {
11
11
  maxFilesPerWorkspace: 8,
12
12
  includeGlobs: ['**/*.ts'],
13
- excludeGlobs: ['**/node_modules/**', '**/dist/**'],
14
- hintGlobs: []
13
+ excludeGlobs: ['**/node_modules/**', '**/dist/**']
15
14
  }
16
15
  }
@@ -10,7 +10,6 @@ export default {
10
10
  sampling: {
11
11
  maxFilesPerWorkspace: 8,
12
12
  includeGlobs: ['**/*.ts'],
13
- excludeGlobs: ['**/node_modules/**'],
14
- hintGlobs: []
13
+ excludeGlobs: ['**/node_modules/**']
15
14
  }
16
15
  }
@@ -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
+ })