@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/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 allowWorkspaceExtractionFailure = options?.allowWorkspaceExtractionFailure ?? false
50
- const onWorkspacesDiscovered = options?.onWorkspacesDiscovered
51
- const onWorkspaceSkipped = options?.onWorkspaceSkipped
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 groupStartedAt = Date.now()
67
- const extractedForGroup = []
68
- const extractedWorkspaces: string[] = []
69
- debugWorkspace('group=%s workspaces=%o', group.name, group.workspaces)
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
- for (const workspaceRel of group.workspaces) {
72
- const workspaceAbs = path.resolve(discovery.rootAbs, workspaceRel)
73
- const sampleStartedAt = Date.now()
74
- const sampled = await sampleWorkspaceFiles(workspaceAbs, config.sampling)
75
- debugWorkspace(
76
- 'group=%s workspace=%s sampled=%d sampleElapsedMs=%d files=%o',
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
- if (extractedCount === 0) {
114
- const context = lastExtractionError ? ` Last error: ${lastExtractionError}` : ''
115
- if (allowWorkspaceExtractionFailure && isSkippableWorkspaceExtractionFailure(lastExtractionError)) {
116
- onWorkspaceSkipped?.({
117
- groupId: group.name,
118
- workspaceRel,
119
- reason: lastExtractionError ?? 'unknown extraction failure'
120
- })
121
- debugWorkspace(
122
- 'group=%s workspace=%s skipped=true reason=%s',
123
- group.name,
124
- workspaceRel,
125
- lastExtractionError ?? 'unknown extraction failure'
126
- )
127
- continue
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
- if (extractedForGroup.length === 0) {
144
- if (allowWorkspaceExtractionFailure) {
145
- debugWorkspace('group=%s skipped=true reason=no-extracted-workspaces', group.name)
146
- continue
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
- const aggregated = aggregateRules(extractedForGroup)
152
- snapshots.set(group.name, buildSnapshot(group.name, extractedWorkspaces, aggregated))
154
+ if (extracted.extracted.length > 0) {
153
155
  debugWorkspace(
154
- 'group=%s aggregatedRules=%d groupElapsedMs=%d',
155
- group.name,
156
- aggregated.size,
157
- Date.now() - groupStartedAt
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
- debugTiming('phase=computeCurrentSnapshots elapsedMs=%d', Date.now() - computeStartedAt)
162
- if (snapshots.size === 0) {
163
- throw new Error('Unable to extract ESLint config from discovered workspaces in zero-config mode')
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
- return snapshots
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
  })