@eslint-config-snapshot/cli 1.2.0 → 1.3.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 +19 -0
- package/dist/index.cjs +840 -340
- package/dist/index.js +832 -331
- package/package.json +2 -2
- package/src/commands/catalog.ts +410 -0
- package/src/commands/check.ts +62 -40
- package/src/formatters.ts +149 -28
- package/src/index.ts +69 -7
- package/src/run-context.ts +50 -30
- package/src/runtime.ts +203 -93
- package/test/cli.integration.test.ts +92 -0
- package/test/cli.terminal.integration.test.ts +74 -0
- package/test/formatters.unit.test.ts +31 -0
package/src/runtime.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
assignGroupsByMatch,
|
|
4
4
|
buildSnapshot,
|
|
5
5
|
diffSnapshots,
|
|
6
|
+
discoverWorkspaceRuleCatalog,
|
|
6
7
|
discoverWorkspaces,
|
|
7
8
|
extractRulesForWorkspaceSamples,
|
|
8
9
|
type GroupAssignment,
|
|
@@ -32,11 +33,22 @@ export type WorkspaceAssignments = {
|
|
|
32
33
|
discovery: WorkspaceDiscovery
|
|
33
34
|
assignments: GroupAssignment[]
|
|
34
35
|
}
|
|
36
|
+
export type GroupRuleCatalog = {
|
|
37
|
+
coreRules: string[]
|
|
38
|
+
pluginRulesByPrefix: Record<string, string[]>
|
|
39
|
+
allRules: string[]
|
|
40
|
+
}
|
|
41
|
+
export type GroupRuleCatalogs = Map<string, GroupRuleCatalog>
|
|
35
42
|
export type SkippedWorkspace = {
|
|
36
43
|
groupId: string
|
|
37
44
|
workspaceRel: string
|
|
38
45
|
reason: string
|
|
39
46
|
}
|
|
47
|
+
type SnapshotComputationOptions = {
|
|
48
|
+
allowWorkspaceExtractionFailure: boolean
|
|
49
|
+
onWorkspacesDiscovered?: (workspacesRel: string[]) => void
|
|
50
|
+
onWorkspaceSkipped?: (skipped: SkippedWorkspace) => void
|
|
51
|
+
}
|
|
40
52
|
|
|
41
53
|
export async function computeCurrentSnapshots(
|
|
42
54
|
cwd: string,
|
|
@@ -46,9 +58,11 @@ export async function computeCurrentSnapshots(
|
|
|
46
58
|
onWorkspaceSkipped?: (skipped: SkippedWorkspace) => void
|
|
47
59
|
}
|
|
48
60
|
): Promise<Map<string, BuiltSnapshot>> {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
const resolvedOptions: SnapshotComputationOptions = {
|
|
62
|
+
allowWorkspaceExtractionFailure: options?.allowWorkspaceExtractionFailure ?? false,
|
|
63
|
+
onWorkspacesDiscovered: options?.onWorkspacesDiscovered,
|
|
64
|
+
onWorkspaceSkipped: options?.onWorkspaceSkipped
|
|
65
|
+
}
|
|
52
66
|
const computeStartedAt = Date.now()
|
|
53
67
|
const configStartedAt = Date.now()
|
|
54
68
|
const config = await loadConfig(cwd)
|
|
@@ -56,113 +70,171 @@ export async function computeCurrentSnapshots(
|
|
|
56
70
|
|
|
57
71
|
const assignmentStartedAt = Date.now()
|
|
58
72
|
const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config)
|
|
59
|
-
onWorkspacesDiscovered?.(discovery.workspacesRel)
|
|
73
|
+
resolvedOptions.onWorkspacesDiscovered?.(discovery.workspacesRel)
|
|
60
74
|
debugTiming('phase=resolveWorkspaceAssignments elapsedMs=%d', Date.now() - assignmentStartedAt)
|
|
61
75
|
debugWorkspace('root=%s groups=%d workspaces=%d', discovery.rootAbs, assignments.length, discovery.workspacesRel.length)
|
|
62
76
|
|
|
63
77
|
const snapshots = new Map<string, BuiltSnapshot>()
|
|
64
78
|
|
|
65
79
|
for (const group of assignments) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
const builtGroup = await buildGroupSnapshot(config, discovery, group, resolvedOptions)
|
|
81
|
+
if (!builtGroup) {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
snapshots.set(group.name, builtGroup)
|
|
85
|
+
}
|
|
70
86
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
group.name,
|
|
78
|
-
workspaceRel,
|
|
79
|
-
sampled.length,
|
|
80
|
-
Date.now() - sampleStartedAt,
|
|
81
|
-
sampled
|
|
82
|
-
)
|
|
83
|
-
let extractedCount = 0
|
|
84
|
-
let lastExtractionError: string | undefined
|
|
85
|
-
|
|
86
|
-
const sampledAbs = sampled.map((sampledRel) => path.resolve(workspaceAbs, sampledRel))
|
|
87
|
-
const extractStartedAt = Date.now()
|
|
88
|
-
const results = await extractRulesForWorkspaceSamples(workspaceAbs, sampledAbs)
|
|
89
|
-
debugTiming(
|
|
90
|
-
'phase=extract group=%s workspace=%s sampled=%d elapsedMs=%d',
|
|
91
|
-
group.name,
|
|
92
|
-
workspaceRel,
|
|
93
|
-
sampledAbs.length,
|
|
94
|
-
Date.now() - extractStartedAt
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
for (const result of results) {
|
|
98
|
-
if (result.rules) {
|
|
99
|
-
extractedForGroup.push(result.rules)
|
|
100
|
-
extractedCount += 1
|
|
101
|
-
continue
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
105
|
-
if (isRecoverableExtractionError(message) || allowWorkspaceExtractionFailure) {
|
|
106
|
-
lastExtractionError = message
|
|
107
|
-
continue
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
throw result.error ?? new Error(message)
|
|
111
|
-
}
|
|
87
|
+
debugTiming('phase=computeCurrentSnapshots elapsedMs=%d', Date.now() - computeStartedAt)
|
|
88
|
+
if (snapshots.size === 0) {
|
|
89
|
+
throw new Error('Unable to extract ESLint config from discovered workspaces in zero-config mode')
|
|
90
|
+
}
|
|
91
|
+
return snapshots
|
|
92
|
+
}
|
|
112
93
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
throw new Error(`Unable to extract ESLint config for workspace ${workspaceRel}.${context}`)
|
|
131
|
-
}
|
|
132
|
-
extractedWorkspaces.push(workspaceRel)
|
|
133
|
-
|
|
134
|
-
debugWorkspace(
|
|
135
|
-
'group=%s workspace=%s extracted=%d failed=%d',
|
|
136
|
-
group.name,
|
|
137
|
-
workspaceRel,
|
|
138
|
-
extractedCount,
|
|
139
|
-
results.length - extractedCount
|
|
140
|
-
)
|
|
94
|
+
async function buildGroupSnapshot(
|
|
95
|
+
config: SnapshotConfig,
|
|
96
|
+
discovery: WorkspaceDiscovery,
|
|
97
|
+
group: GroupAssignment,
|
|
98
|
+
options: SnapshotComputationOptions
|
|
99
|
+
): Promise<BuiltSnapshot | undefined> {
|
|
100
|
+
const groupStartedAt = Date.now()
|
|
101
|
+
const extractedForGroup = []
|
|
102
|
+
const extractedWorkspaces: string[] = []
|
|
103
|
+
debugWorkspace('group=%s workspaces=%o', group.name, group.workspaces)
|
|
104
|
+
|
|
105
|
+
for (const workspaceRel of group.workspaces) {
|
|
106
|
+
const workspaceOutcome = await extractWorkspaceRules(discovery, group.name, workspaceRel, config, options)
|
|
107
|
+
if (workspaceOutcome.kind === 'skip') {
|
|
108
|
+
continue
|
|
141
109
|
}
|
|
110
|
+
extractedForGroup.push(...workspaceOutcome.rules)
|
|
111
|
+
extractedWorkspaces.push(workspaceRel)
|
|
112
|
+
}
|
|
142
113
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
throw new Error(`Unable to extract ESLint config for group ${group.name}: no workspace produced a valid config`)
|
|
114
|
+
if (extractedForGroup.length === 0) {
|
|
115
|
+
if (options.allowWorkspaceExtractionFailure) {
|
|
116
|
+
debugWorkspace('group=%s skipped=true reason=no-extracted-workspaces', group.name)
|
|
117
|
+
return undefined
|
|
149
118
|
}
|
|
119
|
+
throw new Error(`Unable to extract ESLint config for group ${group.name}: no workspace produced a valid config`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const aggregated = aggregateRules(extractedForGroup)
|
|
123
|
+
debugWorkspace(
|
|
124
|
+
'group=%s aggregatedRules=%d groupElapsedMs=%d',
|
|
125
|
+
group.name,
|
|
126
|
+
aggregated.size,
|
|
127
|
+
Date.now() - groupStartedAt
|
|
128
|
+
)
|
|
129
|
+
return buildSnapshot(group.name, extractedWorkspaces, aggregated)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type WorkspaceExtractionOutcome =
|
|
133
|
+
| { kind: 'ok'; rules: Array<NonNullable<Awaited<ReturnType<typeof extractRulesForWorkspaceSamples>>[number]['rules']>> }
|
|
134
|
+
| { kind: 'skip' }
|
|
135
|
+
|
|
136
|
+
async function extractWorkspaceRules(
|
|
137
|
+
discovery: WorkspaceDiscovery,
|
|
138
|
+
groupName: string,
|
|
139
|
+
workspaceRel: string,
|
|
140
|
+
config: SnapshotConfig,
|
|
141
|
+
options: SnapshotComputationOptions
|
|
142
|
+
): Promise<WorkspaceExtractionOutcome> {
|
|
143
|
+
const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
|
|
144
|
+
const sampled = await sampleWorkspaceForGroup(groupName, workspaceRel, workspaceAbs, config)
|
|
145
|
+
const sampledAbs = sampled.map((sampledRel) => path.resolve(workspaceAbs, sampledRel))
|
|
146
|
+
const extracted = await extractRulesForSampledFiles(
|
|
147
|
+
groupName,
|
|
148
|
+
workspaceRel,
|
|
149
|
+
workspaceAbs,
|
|
150
|
+
sampledAbs,
|
|
151
|
+
options.allowWorkspaceExtractionFailure
|
|
152
|
+
)
|
|
150
153
|
|
|
151
|
-
|
|
152
|
-
snapshots.set(group.name, buildSnapshot(group.name, extractedWorkspaces, aggregated))
|
|
154
|
+
if (extracted.extracted.length > 0) {
|
|
153
155
|
debugWorkspace(
|
|
154
|
-
'group=%s
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
'group=%s workspace=%s extracted=%d failed=%d',
|
|
157
|
+
groupName,
|
|
158
|
+
workspaceRel,
|
|
159
|
+
extracted.extracted.length,
|
|
160
|
+
extracted.total - extracted.extracted.length
|
|
158
161
|
)
|
|
162
|
+
return { kind: 'ok', rules: extracted.extracted }
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
165
|
+
const skipReason = extracted.lastError ?? 'unknown extraction failure'
|
|
166
|
+
if (options.allowWorkspaceExtractionFailure && isSkippableWorkspaceExtractionFailure(extracted.lastError)) {
|
|
167
|
+
options.onWorkspaceSkipped?.({
|
|
168
|
+
groupId: groupName,
|
|
169
|
+
workspaceRel,
|
|
170
|
+
reason: skipReason
|
|
171
|
+
})
|
|
172
|
+
debugWorkspace('group=%s workspace=%s skipped=true reason=%s', groupName, workspaceRel, skipReason)
|
|
173
|
+
return { kind: 'skip' }
|
|
164
174
|
}
|
|
165
|
-
|
|
175
|
+
|
|
176
|
+
const context = extracted.lastError ? ` Last error: ${extracted.lastError}` : ''
|
|
177
|
+
throw new Error(`Unable to extract ESLint config for workspace ${workspaceRel}.${context}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function sampleWorkspaceForGroup(
|
|
181
|
+
groupName: string,
|
|
182
|
+
workspaceRel: string,
|
|
183
|
+
workspaceAbs: string,
|
|
184
|
+
config: SnapshotConfig
|
|
185
|
+
): Promise<string[]> {
|
|
186
|
+
const sampleStartedAt = Date.now()
|
|
187
|
+
const sampled = await sampleWorkspaceFiles(workspaceAbs, config.sampling)
|
|
188
|
+
debugWorkspace(
|
|
189
|
+
'group=%s workspace=%s sampled=%d sampleElapsedMs=%d files=%o',
|
|
190
|
+
groupName,
|
|
191
|
+
workspaceRel,
|
|
192
|
+
sampled.length,
|
|
193
|
+
Date.now() - sampleStartedAt,
|
|
194
|
+
sampled
|
|
195
|
+
)
|
|
196
|
+
return sampled
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function extractRulesForSampledFiles(
|
|
200
|
+
groupName: string,
|
|
201
|
+
workspaceRel: string,
|
|
202
|
+
workspaceAbs: string,
|
|
203
|
+
sampledAbs: string[],
|
|
204
|
+
allowWorkspaceExtractionFailure: boolean
|
|
205
|
+
): Promise<{
|
|
206
|
+
extracted: Array<NonNullable<Awaited<ReturnType<typeof extractRulesForWorkspaceSamples>>[number]['rules']>>
|
|
207
|
+
total: number
|
|
208
|
+
lastError?: string
|
|
209
|
+
}> {
|
|
210
|
+
const extractStartedAt = Date.now()
|
|
211
|
+
const results = await extractRulesForWorkspaceSamples(workspaceAbs, sampledAbs)
|
|
212
|
+
debugTiming(
|
|
213
|
+
'phase=extract group=%s workspace=%s sampled=%d elapsedMs=%d',
|
|
214
|
+
groupName,
|
|
215
|
+
workspaceRel,
|
|
216
|
+
sampledAbs.length,
|
|
217
|
+
Date.now() - extractStartedAt
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const extracted: Array<NonNullable<Awaited<ReturnType<typeof extractRulesForWorkspaceSamples>>[number]['rules']>> = []
|
|
221
|
+
let lastError: string | undefined
|
|
222
|
+
for (const result of results) {
|
|
223
|
+
if (result.rules) {
|
|
224
|
+
extracted.push(result.rules)
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
229
|
+
if (isRecoverableExtractionError(message) || allowWorkspaceExtractionFailure) {
|
|
230
|
+
lastError = message
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw result.error ?? new Error(message)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { extracted, total: results.length, lastError }
|
|
166
238
|
}
|
|
167
239
|
|
|
168
240
|
function isRecoverableExtractionError(message: string): boolean {
|
|
@@ -277,3 +349,41 @@ export async function resolveGroupEslintVersions(cwd: string): Promise<GroupEsli
|
|
|
277
349
|
|
|
278
350
|
return result
|
|
279
351
|
}
|
|
352
|
+
|
|
353
|
+
export async function resolveGroupRuleCatalogs(cwd: string): Promise<GroupRuleCatalogs> {
|
|
354
|
+
const config = await loadConfig(cwd)
|
|
355
|
+
const { discovery, assignments } = await resolveWorkspaceAssignments(cwd, config)
|
|
356
|
+
const result: GroupRuleCatalogs = new Map()
|
|
357
|
+
|
|
358
|
+
for (const group of assignments) {
|
|
359
|
+
const coreRules = new Set<string>()
|
|
360
|
+
const allRules = new Set<string>()
|
|
361
|
+
const pluginRulesByPrefix: Record<string, string[]> = {}
|
|
362
|
+
|
|
363
|
+
for (const workspaceRel of group.workspaces) {
|
|
364
|
+
const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
|
|
365
|
+
const catalog = await discoverWorkspaceRuleCatalog(workspaceAbs)
|
|
366
|
+
for (const ruleName of catalog.coreRules) {
|
|
367
|
+
coreRules.add(ruleName)
|
|
368
|
+
}
|
|
369
|
+
for (const ruleName of catalog.allRules) {
|
|
370
|
+
allRules.add(ruleName)
|
|
371
|
+
}
|
|
372
|
+
for (const [prefix, rules] of Object.entries(catalog.pluginRulesByPrefix)) {
|
|
373
|
+
const current = pluginRulesByPrefix[prefix] ?? []
|
|
374
|
+
current.push(...rules)
|
|
375
|
+
pluginRulesByPrefix[prefix] = [...new Set(current)].sort((a, b) => a.localeCompare(b))
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
result.set(group.name, {
|
|
380
|
+
coreRules: [...coreRules].sort((a, b) => a.localeCompare(b)),
|
|
381
|
+
pluginRulesByPrefix: Object.fromEntries(
|
|
382
|
+
Object.entries(pluginRulesByPrefix).sort((a, b) => a[0].localeCompare(b[0]))
|
|
383
|
+
),
|
|
384
|
+
allRules: [...allRules].sort((a, b) => a.localeCompare(b))
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
@@ -19,6 +19,7 @@ beforeEach(async () => {
|
|
|
19
19
|
|
|
20
20
|
await mkdir(path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint/bin'), { recursive: true })
|
|
21
21
|
await mkdir(path.join(fixtureRoot, 'packages/ws-b/node_modules/eslint/bin'), { recursive: true })
|
|
22
|
+
await mkdir(path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint-plugin-alpha'), { recursive: true })
|
|
22
23
|
|
|
23
24
|
await writeFile(
|
|
24
25
|
path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint/bin/eslint.js'),
|
|
@@ -37,6 +38,19 @@ beforeEach(async () => {
|
|
|
37
38
|
path.join(fixtureRoot, 'packages/ws-b/node_modules/eslint/package.json'),
|
|
38
39
|
JSON.stringify({ name: 'eslint', version: '9.0.0' }, null, 2)
|
|
39
40
|
)
|
|
41
|
+
|
|
42
|
+
await writeFile(
|
|
43
|
+
path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint/use-at-your-own-risk.js'),
|
|
44
|
+
"module.exports = { builtinRules: new Map([['no-console', {}], ['no-alert', {}], ['eqeqeq', {}]]) }\n"
|
|
45
|
+
)
|
|
46
|
+
await writeFile(
|
|
47
|
+
path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint-plugin-alpha/package.json'),
|
|
48
|
+
JSON.stringify({ name: 'eslint-plugin-alpha', version: '1.0.0', main: 'index.js' }, null, 2)
|
|
49
|
+
)
|
|
50
|
+
await writeFile(
|
|
51
|
+
path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint-plugin-alpha/index.js'),
|
|
52
|
+
"module.exports = { rules: { 'only-in-catalog': {}, observed: {} } }\n"
|
|
53
|
+
)
|
|
40
54
|
})
|
|
41
55
|
|
|
42
56
|
afterEach(async () => {
|
|
@@ -203,6 +217,33 @@ no-debugger: off
|
|
|
203
217
|
)
|
|
204
218
|
})
|
|
205
219
|
|
|
220
|
+
it('catalog emits available and missing rules in json output', async () => {
|
|
221
|
+
const writeSpy = vi.spyOn(process.stdout, 'write')
|
|
222
|
+
const code = await runCli('catalog', fixtureRoot, ['--missing'])
|
|
223
|
+
expect(code).toBe(0)
|
|
224
|
+
const finalWrite = writeSpy.mock.calls.at(-1)?.[0]
|
|
225
|
+
expect(typeof finalWrite).toBe('string')
|
|
226
|
+
expect(finalWrite).toContain('"missingRules": [')
|
|
227
|
+
expect(finalWrite).toContain('"alpha/observed"')
|
|
228
|
+
expect(finalWrite).toContain('"alpha/only-in-catalog"')
|
|
229
|
+
expect(finalWrite).toContain('"no-alert"')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('catalog --short prints compact catalog summary', async () => {
|
|
233
|
+
const writeSpy = vi.spyOn(process.stdout, 'write')
|
|
234
|
+
const code = await runCli('catalog', fixtureRoot, ['--short', '--missing'])
|
|
235
|
+
expect(code).toBe(0)
|
|
236
|
+
const output = String(writeSpy.mock.calls.at(-1)?.[0] ?? '')
|
|
237
|
+
expect(output).toContain('📦 total: 2/5 in use')
|
|
238
|
+
expect(output).toContain('🧱 core: 2/3 in use')
|
|
239
|
+
expect(output).toContain('🔌 plugins tracked: 1')
|
|
240
|
+
expect(output).toContain(' - alpha: 0/2 in use')
|
|
241
|
+
expect(output).toContain('🕳️ missing list (3):')
|
|
242
|
+
expect(output).toContain('alpha/observed')
|
|
243
|
+
expect(output).toContain('alpha/only-in-catalog')
|
|
244
|
+
expect(output).toContain('no-alert')
|
|
245
|
+
})
|
|
246
|
+
|
|
206
247
|
it('init creates scaffold config file when target=file', async () => {
|
|
207
248
|
const tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-init-'))
|
|
208
249
|
const code = await runCli('init', tmp, ['--yes', '--target', 'file', '--preset', 'full'])
|
|
@@ -260,6 +301,57 @@ no-debugger: off
|
|
|
260
301
|
expect(await runCli('check', fixtureRoot)).toBe(0)
|
|
261
302
|
})
|
|
262
303
|
|
|
304
|
+
it('supports catalog baseline update/check and experimental combined mode', async () => {
|
|
305
|
+
expect(await runCli('catalog-update', fixtureRoot)).toBe(0)
|
|
306
|
+
expect(await runCli('catalog-check', fixtureRoot)).toBe(0)
|
|
307
|
+
|
|
308
|
+
const catalogBaselineRaw = await readFile(path.join(fixtureRoot, '.eslint-config-snapshot/default.catalog.json'), 'utf8')
|
|
309
|
+
const catalogBaseline = JSON.parse(catalogBaselineRaw) as {
|
|
310
|
+
formatVersion: number
|
|
311
|
+
groupId: string
|
|
312
|
+
totalStats: { totalAvailable: number; inUse: number; error: number; warn: number; off: number }
|
|
313
|
+
}
|
|
314
|
+
expect(catalogBaseline.formatVersion).toBe(1)
|
|
315
|
+
expect(catalogBaseline.groupId).toBe('default')
|
|
316
|
+
expect(catalogBaseline.totalStats).toMatchObject({
|
|
317
|
+
totalAvailable: 5,
|
|
318
|
+
inUse: 2,
|
|
319
|
+
error: 2,
|
|
320
|
+
warn: 0,
|
|
321
|
+
off: 0
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
expect(await runCli(undefined, fixtureRoot, ['--update', '--experimental-with-catalog'])).toBe(0)
|
|
325
|
+
expect(await runCli(undefined, fixtureRoot, ['--experimental-with-catalog'])).toBe(0)
|
|
326
|
+
|
|
327
|
+
await writeFile(
|
|
328
|
+
path.join(fixtureRoot, 'packages/ws-a/node_modules/eslint/bin/eslint.js'),
|
|
329
|
+
"console.log(JSON.stringify({ rules: { 'no-console': 1, eqeqeq: [1, 'always'] } }))\n"
|
|
330
|
+
)
|
|
331
|
+
expect(await runCli('catalog-check', fixtureRoot)).toBe(1)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('uses experimentalWithCatalog from config without CLI flag', async () => {
|
|
335
|
+
await writeFile(
|
|
336
|
+
path.join(fixtureRoot, 'eslint-config-snapshot.config.mjs'),
|
|
337
|
+
`export default {
|
|
338
|
+
experimentalWithCatalog: true,
|
|
339
|
+
workspaceInput: { mode: 'manual', workspaces: ['packages/ws-a', 'packages/ws-b'] },
|
|
340
|
+
grouping: { mode: 'match', groups: [{ name: 'default', match: ['**/*'] }] },
|
|
341
|
+
sampling: { maxFilesPerWorkspace: 8, includeGlobs: ['**/*.ts'], excludeGlobs: ['**/node_modules/**'] }
|
|
342
|
+
}
|
|
343
|
+
`
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
expect(await runCli(undefined, fixtureRoot, ['--update'])).toBe(0)
|
|
347
|
+
const catalogRaw = await readFile(path.join(fixtureRoot, '.eslint-config-snapshot/default.catalog.json'), 'utf8')
|
|
348
|
+
const catalog = JSON.parse(catalogRaw) as { formatVersion: number; groupId: string }
|
|
349
|
+
expect(catalog.formatVersion).toBe(1)
|
|
350
|
+
expect(catalog.groupId).toBe('default')
|
|
351
|
+
|
|
352
|
+
expect(await runCli(undefined, fixtureRoot, [])).toBe(0)
|
|
353
|
+
})
|
|
354
|
+
|
|
263
355
|
it('supports ordered multi-group matching with first match wins', async () => {
|
|
264
356
|
const tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-cli-grouped-'))
|
|
265
357
|
await cp(fixtureRoot, tmp, { recursive: true })
|
|
@@ -30,6 +30,7 @@ beforeEach(async () => {
|
|
|
30
30
|
await cp(fixtureRoot, repoRoot, { recursive: true })
|
|
31
31
|
await mkdir(path.join(repoRoot, 'packages/ws-a/node_modules/eslint/bin'), { recursive: true })
|
|
32
32
|
await mkdir(path.join(repoRoot, 'packages/ws-b/node_modules/eslint/bin'), { recursive: true })
|
|
33
|
+
await mkdir(path.join(repoRoot, 'packages/ws-a/node_modules/eslint-plugin-alpha'), { recursive: true })
|
|
33
34
|
|
|
34
35
|
await writeFile(
|
|
35
36
|
path.join(repoRoot, 'packages/ws-a/node_modules/eslint/bin/eslint.js'),
|
|
@@ -48,6 +49,18 @@ beforeEach(async () => {
|
|
|
48
49
|
path.join(repoRoot, 'packages/ws-b/node_modules/eslint/package.json'),
|
|
49
50
|
JSON.stringify({ name: 'eslint', version: '9.0.0' }, null, 2)
|
|
50
51
|
)
|
|
52
|
+
await writeFile(
|
|
53
|
+
path.join(repoRoot, 'packages/ws-a/node_modules/eslint/use-at-your-own-risk.js'),
|
|
54
|
+
"module.exports = { builtinRules: new Map([['no-console', {}], ['no-alert', {}], ['eqeqeq', {}]]) }\n"
|
|
55
|
+
)
|
|
56
|
+
await writeFile(
|
|
57
|
+
path.join(repoRoot, 'packages/ws-a/node_modules/eslint-plugin-alpha/package.json'),
|
|
58
|
+
JSON.stringify({ name: 'eslint-plugin-alpha', version: '1.0.0', main: 'index.js' }, null, 2)
|
|
59
|
+
)
|
|
60
|
+
await writeFile(
|
|
61
|
+
path.join(repoRoot, 'packages/ws-a/node_modules/eslint-plugin-alpha/index.js'),
|
|
62
|
+
"module.exports = { rules: { 'only-in-catalog': {}, observed: {} } }\n"
|
|
63
|
+
)
|
|
51
64
|
})
|
|
52
65
|
|
|
53
66
|
afterEach(async () => {
|
|
@@ -66,6 +79,9 @@ describe('cli terminal invocation', () => {
|
|
|
66
79
|
expect(result.stdout).toContain('check [options]')
|
|
67
80
|
expect(result.stdout).toContain('update|snapshot')
|
|
68
81
|
expect(result.stdout).toContain('print [options]')
|
|
82
|
+
expect(result.stdout).toContain('catalog [options]')
|
|
83
|
+
expect(result.stdout).toContain('catalog-check')
|
|
84
|
+
expect(result.stdout).toContain('catalog-update')
|
|
69
85
|
expect(result.stdout).toContain('config [options]')
|
|
70
86
|
expect(result.stdout).toContain('init')
|
|
71
87
|
expect(result.stderr).toBe('')
|
|
@@ -218,6 +234,64 @@ no-debugger: off
|
|
|
218
234
|
expect(result.stderr).toBe('')
|
|
219
235
|
})
|
|
220
236
|
|
|
237
|
+
it('catalog --missing returns deterministic json output', () => {
|
|
238
|
+
const result = run(['catalog', '--missing'])
|
|
239
|
+
expect(result.status).toBe(0)
|
|
240
|
+
expect(result.stdout).toContain('"groupId": "default"')
|
|
241
|
+
expect(result.stdout).toContain('"totalStats"')
|
|
242
|
+
expect(result.stdout).toContain('"coreStats"')
|
|
243
|
+
expect(result.stdout).toContain('"pluginStats"')
|
|
244
|
+
expect(result.stdout).toContain('"missingRules"')
|
|
245
|
+
expect(result.stdout).toContain('"alpha/observed"')
|
|
246
|
+
expect(result.stdout).toContain('"alpha/only-in-catalog"')
|
|
247
|
+
expect(result.stdout).toContain('"no-alert"')
|
|
248
|
+
expect(result.stderr).toBe('')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('catalog --short --missing returns compact output', () => {
|
|
252
|
+
const result = run(['catalog', '--short', '--missing'])
|
|
253
|
+
expect(result.status).toBe(0)
|
|
254
|
+
expect(result.stdout).toContain('📦 total: 2/5 in use')
|
|
255
|
+
expect(result.stdout).toContain('🧱 core: 2/3 in use')
|
|
256
|
+
expect(result.stdout).toContain('🔌 plugins tracked: 1')
|
|
257
|
+
expect(result.stdout).toContain(' - alpha: 0/2 in use')
|
|
258
|
+
expect(result.stdout).toContain('🕳️ missing list (3):')
|
|
259
|
+
expect(result.stdout).toContain('alpha/observed')
|
|
260
|
+
expect(result.stdout).toContain('alpha/only-in-catalog')
|
|
261
|
+
expect(result.stdout).toContain('no-alert')
|
|
262
|
+
expect(result.stderr).toBe('')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('catalog-update writes baseline and catalog-check returns clean', () => {
|
|
266
|
+
const update = run(['catalog-update'])
|
|
267
|
+
expect(update.status).toBe(0)
|
|
268
|
+
expect(update.stdout).toContain('Catalog baseline updated:')
|
|
269
|
+
expect(update.stdout).toContain('📦 total:')
|
|
270
|
+
expect(update.stdout).toContain('🔌 plugins tracked:')
|
|
271
|
+
expect(update.stderr).toBe('')
|
|
272
|
+
|
|
273
|
+
const check = run(['catalog-check'])
|
|
274
|
+
expect(check.status).toBe(0)
|
|
275
|
+
expect(check.stdout).toContain('Great news: no catalog drift detected.')
|
|
276
|
+
expect(check.stdout).toContain('📦 total:')
|
|
277
|
+
expect(check.stdout).toContain('🔌 plugins tracked:')
|
|
278
|
+
expect(check.stderr).toBe('')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('default experimental mode updates and checks catalog baseline', () => {
|
|
282
|
+
const update = run(['--update', '--experimental-with-catalog'])
|
|
283
|
+
expect(update.status).toBe(0)
|
|
284
|
+
expect(update.stdout).toContain('baseline was successfully created')
|
|
285
|
+
expect(update.stdout).toContain('Catalog baseline updated:')
|
|
286
|
+
expect(update.stderr).toBe('')
|
|
287
|
+
|
|
288
|
+
const check = run(['--experimental-with-catalog'])
|
|
289
|
+
expect(check.status).toBe(0)
|
|
290
|
+
expect(check.stdout).toContain('Great news: no snapshot drift detected.')
|
|
291
|
+
expect(check.stdout).toContain('Great news: no catalog drift detected.')
|
|
292
|
+
expect(check.stderr).toBe('')
|
|
293
|
+
})
|
|
294
|
+
|
|
221
295
|
it('init handles success and existing-file error paths', async () => {
|
|
222
296
|
const initRoot = path.join(tmpDir, 'init-case')
|
|
223
297
|
await rm(initRoot, { recursive: true, force: true })
|
|
@@ -44,4 +44,35 @@ describe('output helpers', () => {
|
|
|
44
44
|
)
|
|
45
45
|
expect(summary).toEqual({ groups: 1, rules: 3, error: 1, warn: 1, off: 1 })
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
it('deduplicates rules across groups in summary', () => {
|
|
49
|
+
const summary = summarizeSnapshots(
|
|
50
|
+
new Map([
|
|
51
|
+
[
|
|
52
|
+
'group-a',
|
|
53
|
+
{
|
|
54
|
+
groupId: 'group-a',
|
|
55
|
+
workspaces: ['packages/a'],
|
|
56
|
+
rules: {
|
|
57
|
+
shared: ['warn'],
|
|
58
|
+
onlyA: ['off']
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
'group-b',
|
|
64
|
+
{
|
|
65
|
+
groupId: 'group-b',
|
|
66
|
+
workspaces: ['packages/b'],
|
|
67
|
+
rules: {
|
|
68
|
+
shared: ['error'],
|
|
69
|
+
onlyB: ['warn']
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
])
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(summary).toEqual({ groups: 2, rules: 3, error: 1, warn: 1, off: 1 })
|
|
77
|
+
})
|
|
47
78
|
})
|