@eslint-config-snapshot/cli 1.3.0 → 1.3.2

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @eslint-config-snapshot/cli
2
2
 
3
+ ## 1.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Refine catalog output defaults with optional detailed grouped rule states and semantic colorized labels.
8
+ - Updated dependencies
9
+ - @eslint-config-snapshot/api@1.3.2
10
+
11
+ ## 1.3.1
12
+
13
+ ### Patch Changes
14
+
15
+ - Improve catalog check/update summaries and dedupe baseline totals across groups.
16
+ - Updated dependencies
17
+ - @eslint-config-snapshot/api@1.3.1
18
+
3
19
  ## 1.3.0
4
20
 
5
21
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -102,7 +102,7 @@ function summarizeChanges(changes) {
102
102
  return { introduced, removed, severity, options, workspace };
103
103
  }
104
104
  function summarizeSnapshots(snapshots) {
105
- const { rules, error, warn, off } = countRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules));
105
+ const { rules, error, warn, off } = countUniqueRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules));
106
106
  return { groups: snapshots.size, rules, error, warn, off };
107
107
  }
108
108
  function countUniqueWorkspaces(snapshots) {
@@ -185,7 +185,8 @@ function formatShortConfig(payload) {
185
185
  return `${lines.join("\n")}
186
186
  `;
187
187
  }
188
- function formatShortCatalog(catalogs, missingOnly) {
188
+ function formatShortCatalog(catalogs, options) {
189
+ const { missingOnly, detailed, color } = options;
189
190
  const lines = [];
190
191
  const sorted = [...catalogs].sort((a, b) => a.groupId.localeCompare(b.groupId));
191
192
  const showGroupHeader = sorted.length > 1 || sorted.some((catalog) => catalog.groupId !== "default");
@@ -201,10 +202,8 @@ function formatShortCatalog(catalogs, missingOnly) {
201
202
  for (const plugin of catalog.pluginStats) {
202
203
  lines.push(` - ${plugin.pluginId}: ${formatUsageLine(plugin)}`);
203
204
  }
204
- const detailRules = missingOnly ? catalog.missingRules : catalog.availableRules;
205
- lines.push(`${missingOnly ? "\u{1F573}\uFE0F missing list" : "\u{1F4DA} available list"} (${detailRules.length}):`);
206
- for (const ruleName of detailRules) {
207
- lines.push(` - ${ruleName}`);
205
+ if (detailed) {
206
+ appendRuleStateGroups(lines, catalog, missingOnly, color);
208
207
  }
209
208
  if (showGroupHeader) {
210
209
  lines.push("");
@@ -219,6 +218,44 @@ function formatShortCatalog(catalogs, missingOnly) {
219
218
  function formatUsageLine(stats) {
220
219
  return `${stats.inUse}/${stats.totalAvailable} in use (${stats.inUsePct}%) | error ${stats.error} | warn ${stats.warn} | off ${stats.off} | not used ${stats.missing}`;
221
220
  }
221
+ function appendRuleStateGroups(lines, catalog, missingOnly, color) {
222
+ if (missingOnly) {
223
+ appendRuleList(lines, color?.yellow("\u{1F7E1} unused") ?? "\u{1F7E1} unused", catalog.missingRules);
224
+ return;
225
+ }
226
+ const observedRuleSet = new Set(catalog.observedRules);
227
+ const errorRules = [];
228
+ const warnRules = [];
229
+ const offRules = [];
230
+ const unusedRules = [];
231
+ for (const ruleName of catalog.availableRules) {
232
+ if (!observedRuleSet.has(ruleName)) {
233
+ unusedRules.push(ruleName);
234
+ continue;
235
+ }
236
+ const level = catalog.observedRuleLevels[ruleName];
237
+ if (level === "off") {
238
+ offRules.push(ruleName);
239
+ continue;
240
+ }
241
+ if (level === "error") {
242
+ errorRules.push(ruleName);
243
+ continue;
244
+ }
245
+ warnRules.push(ruleName);
246
+ }
247
+ appendRuleList(lines, color?.green("\u{1F7E2} error") ?? "\u{1F7E2} error", errorRules);
248
+ appendRuleList(lines, color?.green("\u{1F7E2} warn") ?? "\u{1F7E2} warn", warnRules);
249
+ appendRuleList(lines, color?.cyan("\u{1F535} off") ?? "\u{1F535} off", offRules);
250
+ appendRuleList(lines, color?.yellow("\u{1F7E1} unused") ?? "\u{1F7E1} unused", unusedRules);
251
+ }
252
+ function appendRuleList(lines, label, rules) {
253
+ lines.push(`${label} (${rules.length}):`);
254
+ const sortedRules = [...rules].sort((a, b) => a.localeCompare(b));
255
+ for (const ruleName of sortedRules) {
256
+ lines.push(` - ${ruleName}`);
257
+ }
258
+ }
222
259
  function formatCommandDisplayLabel(commandLabel) {
223
260
  switch (commandLabel) {
224
261
  case "check":
@@ -276,25 +313,34 @@ function formatBaselineSummaryLines(summary, workspaceCount) {
276
313
  - \u{1F39A}\uFE0F severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off
277
314
  `;
278
315
  }
279
- function countRuleSeverities(ruleObjects) {
280
- let rules = 0;
316
+ function countUniqueRuleSeverities(ruleObjects) {
317
+ const severityRank = { off: 0, warn: 1, error: 2 };
318
+ const severityByRule = /* @__PURE__ */ new Map();
319
+ for (const rulesObject of ruleObjects) {
320
+ for (const [ruleName, entry] of Object.entries(rulesObject)) {
321
+ const nextSeverity = getPrimarySeverity(entry);
322
+ if (!nextSeverity) {
323
+ continue;
324
+ }
325
+ const currentSeverity = severityByRule.get(ruleName);
326
+ if (!currentSeverity || severityRank[nextSeverity] > severityRank[currentSeverity]) {
327
+ severityByRule.set(ruleName, nextSeverity);
328
+ }
329
+ }
330
+ }
281
331
  let error = 0;
282
332
  let warn = 0;
283
333
  let off = 0;
284
- for (const rulesObject of ruleObjects) {
285
- for (const entry of Object.values(rulesObject)) {
286
- rules += 1;
287
- const severity = getPrimarySeverity(entry);
288
- if (severity === "error") {
289
- error += 1;
290
- } else if (severity === "warn") {
291
- warn += 1;
292
- } else {
293
- off += 1;
294
- }
334
+ for (const severity of severityByRule.values()) {
335
+ if (severity === "error") {
336
+ error += 1;
337
+ } else if (severity === "warn") {
338
+ warn += 1;
339
+ } else {
340
+ off += 1;
295
341
  }
296
342
  }
297
- return { rules, error, warn, off };
343
+ return { rules: severityByRule.size, error, warn, off };
298
344
  }
299
345
  function getPrimarySeverity(entry) {
300
346
  if (!entry) {
@@ -845,10 +891,10 @@ function isDefaultEquivalentConfig(config) {
845
891
 
846
892
  // src/commands/catalog.ts
847
893
  var CATALOG_FILE_SUFFIX = ".catalog.json";
848
- async function executeCatalog(cwd, terminal, snapshotDir, format, missingOnly) {
894
+ async function executeCatalog(cwd, terminal, snapshotDir, format, missingOnly, detailed) {
849
895
  const rows = await computeCatalogRows(cwd, terminal, snapshotDir, `catalog:${format}`, true);
850
896
  if (format === "short") {
851
- terminal.write(formatShortCatalog(rows, missingOnly));
897
+ terminal.write(formatShortCatalog(rows, { missingOnly, detailed, color: terminal.colors }));
852
898
  return 0;
853
899
  }
854
900
  const output = rows.map((row) => {
@@ -871,10 +917,12 @@ async function executeCatalogUpdate(cwd, terminal, snapshotDir) {
871
917
  const rows = await computeCatalogRows(cwd, terminal, snapshotDir, "catalog:update", false);
872
918
  await writeCatalogBaselineFiles(cwd, snapshotDir, rows);
873
919
  const groups = rows.length;
874
- const available = rows.reduce((sum, row) => sum + row.totalStats.totalAvailable, 0);
875
- const inUse = rows.reduce((sum, row) => sum + row.totalStats.inUse, 0);
920
+ const available = countUniqueRules(rows.map((row) => row.availableRules));
921
+ const inUse = countUniqueRules(rows.map((row) => row.observedRules.filter((ruleName) => row.availableRules.includes(ruleName))));
876
922
  terminal.write(`\u{1F9EA} Catalog baseline updated: ${groups} groups, ${available} available rules, ${inUse} currently in use.
877
923
  `);
924
+ terminal.section("\u{1F4CA} Catalog summary");
925
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }));
878
926
  return 0;
879
927
  }
880
928
  async function executeCatalogCheck(cwd, terminal, snapshotDir) {
@@ -889,8 +937,12 @@ async function executeCatalogCheck(cwd, terminal, snapshotDir) {
889
937
  const diffs = compareCatalogBaselines(stored, current);
890
938
  if (diffs.length === 0) {
891
939
  terminal.write("Great news: no catalog drift detected.\n");
940
+ terminal.section("\u{1F4CA} Catalog summary");
941
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }));
892
942
  return 0;
893
943
  }
944
+ terminal.section("\u{1F4CA} Catalog summary");
945
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }));
894
946
  terminal.write(`\u26A0\uFE0F Heads up: catalog drift detected in ${diffs.length} groups.
895
947
  `);
896
948
  for (const diff of diffs) {
@@ -935,6 +987,9 @@ async function computeCatalogRows(cwd, terminal, snapshotDir, commandLabel, prin
935
987
  const availableRules = catalog?.allRules ?? [];
936
988
  const availableRuleSet = new Set(availableRules);
937
989
  const missingRules = availableRules.filter((ruleName) => !snapshot.rules[ruleName]);
990
+ const observedRuleLevels = Object.fromEntries(
991
+ observedRules.map((ruleName) => [ruleName, getPrimarySeverity2(snapshot.rules[ruleName])])
992
+ );
938
993
  const observedOffRules = observedRules.filter((ruleName) => isRuleOffOnly(snapshot.rules[ruleName]));
939
994
  const observedActiveRules = observedRules.filter((ruleName) => !isRuleOffOnly(snapshot.rules[ruleName]));
940
995
  const observedOutsideCatalog = observedRules.filter((ruleName) => !availableRuleSet.has(ruleName)).length;
@@ -955,6 +1010,7 @@ async function computeCatalogRows(cwd, terminal, snapshotDir, commandLabel, prin
955
1010
  coreRules,
956
1011
  pluginRulesByPrefix,
957
1012
  observedRules,
1013
+ observedRuleLevels,
958
1014
  missingRules,
959
1015
  observedOffRules,
960
1016
  observedActiveRules,
@@ -1131,6 +1187,15 @@ function toPercent(value, total) {
1131
1187
  }
1132
1188
  return Number((value / total * 100).toFixed(1));
1133
1189
  }
1190
+ function countUniqueRules(ruleLists) {
1191
+ const unique = /* @__PURE__ */ new Set();
1192
+ for (const rules of ruleLists) {
1193
+ for (const rule of rules) {
1194
+ unique.add(rule);
1195
+ }
1196
+ }
1197
+ return unique.size;
1198
+ }
1134
1199
 
1135
1200
  // src/commands/check.ts
1136
1201
  var UPDATE_HINT = "Tip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n";
@@ -1751,6 +1816,7 @@ function createColorizer() {
1751
1816
  return {
1752
1817
  green: (text) => wrap("32", text),
1753
1818
  yellow: (text) => wrap("33", text),
1819
+ cyan: (text) => wrap("36", text),
1754
1820
  red: (text) => wrap("31", text),
1755
1821
  bold: (text) => wrap("1", text),
1756
1822
  dim: (text) => wrap("2", text)
@@ -1877,9 +1943,9 @@ function createProgram(cwd, terminal, onActionExit) {
1877
1943
  const format = opts.short ? "short" : opts.format;
1878
1944
  onActionExit(await executePrint(cwd, terminal, SNAPSHOT_DIR, format));
1879
1945
  });
1880
- program.command("catalog").description("Print discovered rule catalog and missing rules").option("--format <format>", "Output format: json|short", parsePrintFormat, "json").option("--short", "Alias for --format short").option("--missing", "Only print rules that are available but not observed in current snapshots").action(async (opts) => {
1946
+ program.command("catalog").description("Print discovered rule catalog and missing rules").option("--format <format>", "Output format: json|short", parsePrintFormat, "json").option("--short", "Alias for --format short").option("--detailed", "Show detailed rule lists grouped by error, warn, off, and unused").option("--missing", "Only print rules that are available but not observed in current snapshots").action(async (opts) => {
1881
1947
  const format = opts.short ? "short" : opts.format;
1882
- onActionExit(await executeCatalog(cwd, terminal, SNAPSHOT_DIR, format, Boolean(opts.missing)));
1948
+ onActionExit(await executeCatalog(cwd, terminal, SNAPSHOT_DIR, format, Boolean(opts.missing), Boolean(opts.detailed)));
1883
1949
  });
1884
1950
  program.command("catalog-check").description("Compare current catalog against stored catalog baseline").action(async () => {
1885
1951
  onActionExit(await executeCatalogCheck(cwd, terminal, SNAPSHOT_DIR));
package/dist/index.js CHANGED
@@ -67,7 +67,7 @@ function summarizeChanges(changes) {
67
67
  return { introduced, removed, severity, options, workspace };
68
68
  }
69
69
  function summarizeSnapshots(snapshots) {
70
- const { rules, error, warn, off } = countRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules));
70
+ const { rules, error, warn, off } = countUniqueRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules));
71
71
  return { groups: snapshots.size, rules, error, warn, off };
72
72
  }
73
73
  function countUniqueWorkspaces(snapshots) {
@@ -150,7 +150,8 @@ function formatShortConfig(payload) {
150
150
  return `${lines.join("\n")}
151
151
  `;
152
152
  }
153
- function formatShortCatalog(catalogs, missingOnly) {
153
+ function formatShortCatalog(catalogs, options) {
154
+ const { missingOnly, detailed, color } = options;
154
155
  const lines = [];
155
156
  const sorted = [...catalogs].sort((a, b) => a.groupId.localeCompare(b.groupId));
156
157
  const showGroupHeader = sorted.length > 1 || sorted.some((catalog) => catalog.groupId !== "default");
@@ -166,10 +167,8 @@ function formatShortCatalog(catalogs, missingOnly) {
166
167
  for (const plugin of catalog.pluginStats) {
167
168
  lines.push(` - ${plugin.pluginId}: ${formatUsageLine(plugin)}`);
168
169
  }
169
- const detailRules = missingOnly ? catalog.missingRules : catalog.availableRules;
170
- lines.push(`${missingOnly ? "\u{1F573}\uFE0F missing list" : "\u{1F4DA} available list"} (${detailRules.length}):`);
171
- for (const ruleName of detailRules) {
172
- lines.push(` - ${ruleName}`);
170
+ if (detailed) {
171
+ appendRuleStateGroups(lines, catalog, missingOnly, color);
173
172
  }
174
173
  if (showGroupHeader) {
175
174
  lines.push("");
@@ -184,6 +183,44 @@ function formatShortCatalog(catalogs, missingOnly) {
184
183
  function formatUsageLine(stats) {
185
184
  return `${stats.inUse}/${stats.totalAvailable} in use (${stats.inUsePct}%) | error ${stats.error} | warn ${stats.warn} | off ${stats.off} | not used ${stats.missing}`;
186
185
  }
186
+ function appendRuleStateGroups(lines, catalog, missingOnly, color) {
187
+ if (missingOnly) {
188
+ appendRuleList(lines, color?.yellow("\u{1F7E1} unused") ?? "\u{1F7E1} unused", catalog.missingRules);
189
+ return;
190
+ }
191
+ const observedRuleSet = new Set(catalog.observedRules);
192
+ const errorRules = [];
193
+ const warnRules = [];
194
+ const offRules = [];
195
+ const unusedRules = [];
196
+ for (const ruleName of catalog.availableRules) {
197
+ if (!observedRuleSet.has(ruleName)) {
198
+ unusedRules.push(ruleName);
199
+ continue;
200
+ }
201
+ const level = catalog.observedRuleLevels[ruleName];
202
+ if (level === "off") {
203
+ offRules.push(ruleName);
204
+ continue;
205
+ }
206
+ if (level === "error") {
207
+ errorRules.push(ruleName);
208
+ continue;
209
+ }
210
+ warnRules.push(ruleName);
211
+ }
212
+ appendRuleList(lines, color?.green("\u{1F7E2} error") ?? "\u{1F7E2} error", errorRules);
213
+ appendRuleList(lines, color?.green("\u{1F7E2} warn") ?? "\u{1F7E2} warn", warnRules);
214
+ appendRuleList(lines, color?.cyan("\u{1F535} off") ?? "\u{1F535} off", offRules);
215
+ appendRuleList(lines, color?.yellow("\u{1F7E1} unused") ?? "\u{1F7E1} unused", unusedRules);
216
+ }
217
+ function appendRuleList(lines, label, rules) {
218
+ lines.push(`${label} (${rules.length}):`);
219
+ const sortedRules = [...rules].sort((a, b) => a.localeCompare(b));
220
+ for (const ruleName of sortedRules) {
221
+ lines.push(` - ${ruleName}`);
222
+ }
223
+ }
187
224
  function formatCommandDisplayLabel(commandLabel) {
188
225
  switch (commandLabel) {
189
226
  case "check":
@@ -241,25 +278,34 @@ function formatBaselineSummaryLines(summary, workspaceCount) {
241
278
  - \u{1F39A}\uFE0F severity mix: ${summary.error} errors, ${summary.warn} warnings, ${summary.off} off
242
279
  `;
243
280
  }
244
- function countRuleSeverities(ruleObjects) {
245
- let rules = 0;
281
+ function countUniqueRuleSeverities(ruleObjects) {
282
+ const severityRank = { off: 0, warn: 1, error: 2 };
283
+ const severityByRule = /* @__PURE__ */ new Map();
284
+ for (const rulesObject of ruleObjects) {
285
+ for (const [ruleName, entry] of Object.entries(rulesObject)) {
286
+ const nextSeverity = getPrimarySeverity(entry);
287
+ if (!nextSeverity) {
288
+ continue;
289
+ }
290
+ const currentSeverity = severityByRule.get(ruleName);
291
+ if (!currentSeverity || severityRank[nextSeverity] > severityRank[currentSeverity]) {
292
+ severityByRule.set(ruleName, nextSeverity);
293
+ }
294
+ }
295
+ }
246
296
  let error = 0;
247
297
  let warn = 0;
248
298
  let off = 0;
249
- for (const rulesObject of ruleObjects) {
250
- for (const entry of Object.values(rulesObject)) {
251
- rules += 1;
252
- const severity = getPrimarySeverity(entry);
253
- if (severity === "error") {
254
- error += 1;
255
- } else if (severity === "warn") {
256
- warn += 1;
257
- } else {
258
- off += 1;
259
- }
299
+ for (const severity of severityByRule.values()) {
300
+ if (severity === "error") {
301
+ error += 1;
302
+ } else if (severity === "warn") {
303
+ warn += 1;
304
+ } else {
305
+ off += 1;
260
306
  }
261
307
  }
262
- return { rules, error, warn, off };
308
+ return { rules: severityByRule.size, error, warn, off };
263
309
  }
264
310
  function getPrimarySeverity(entry) {
265
311
  if (!entry) {
@@ -824,10 +870,10 @@ function isDefaultEquivalentConfig(config) {
824
870
 
825
871
  // src/commands/catalog.ts
826
872
  var CATALOG_FILE_SUFFIX = ".catalog.json";
827
- async function executeCatalog(cwd, terminal, snapshotDir, format, missingOnly) {
873
+ async function executeCatalog(cwd, terminal, snapshotDir, format, missingOnly, detailed) {
828
874
  const rows = await computeCatalogRows(cwd, terminal, snapshotDir, `catalog:${format}`, true);
829
875
  if (format === "short") {
830
- terminal.write(formatShortCatalog(rows, missingOnly));
876
+ terminal.write(formatShortCatalog(rows, { missingOnly, detailed, color: terminal.colors }));
831
877
  return 0;
832
878
  }
833
879
  const output = rows.map((row) => {
@@ -850,10 +896,12 @@ async function executeCatalogUpdate(cwd, terminal, snapshotDir) {
850
896
  const rows = await computeCatalogRows(cwd, terminal, snapshotDir, "catalog:update", false);
851
897
  await writeCatalogBaselineFiles(cwd, snapshotDir, rows);
852
898
  const groups = rows.length;
853
- const available = rows.reduce((sum, row) => sum + row.totalStats.totalAvailable, 0);
854
- const inUse = rows.reduce((sum, row) => sum + row.totalStats.inUse, 0);
899
+ const available = countUniqueRules(rows.map((row) => row.availableRules));
900
+ const inUse = countUniqueRules(rows.map((row) => row.observedRules.filter((ruleName) => row.availableRules.includes(ruleName))));
855
901
  terminal.write(`\u{1F9EA} Catalog baseline updated: ${groups} groups, ${available} available rules, ${inUse} currently in use.
856
902
  `);
903
+ terminal.section("\u{1F4CA} Catalog summary");
904
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }));
857
905
  return 0;
858
906
  }
859
907
  async function executeCatalogCheck(cwd, terminal, snapshotDir) {
@@ -868,8 +916,12 @@ async function executeCatalogCheck(cwd, terminal, snapshotDir) {
868
916
  const diffs = compareCatalogBaselines(stored, current);
869
917
  if (diffs.length === 0) {
870
918
  terminal.write("Great news: no catalog drift detected.\n");
919
+ terminal.section("\u{1F4CA} Catalog summary");
920
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }));
871
921
  return 0;
872
922
  }
923
+ terminal.section("\u{1F4CA} Catalog summary");
924
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }));
873
925
  terminal.write(`\u26A0\uFE0F Heads up: catalog drift detected in ${diffs.length} groups.
874
926
  `);
875
927
  for (const diff of diffs) {
@@ -914,6 +966,9 @@ async function computeCatalogRows(cwd, terminal, snapshotDir, commandLabel, prin
914
966
  const availableRules = catalog?.allRules ?? [];
915
967
  const availableRuleSet = new Set(availableRules);
916
968
  const missingRules = availableRules.filter((ruleName) => !snapshot.rules[ruleName]);
969
+ const observedRuleLevels = Object.fromEntries(
970
+ observedRules.map((ruleName) => [ruleName, getPrimarySeverity2(snapshot.rules[ruleName])])
971
+ );
917
972
  const observedOffRules = observedRules.filter((ruleName) => isRuleOffOnly(snapshot.rules[ruleName]));
918
973
  const observedActiveRules = observedRules.filter((ruleName) => !isRuleOffOnly(snapshot.rules[ruleName]));
919
974
  const observedOutsideCatalog = observedRules.filter((ruleName) => !availableRuleSet.has(ruleName)).length;
@@ -934,6 +989,7 @@ async function computeCatalogRows(cwd, terminal, snapshotDir, commandLabel, prin
934
989
  coreRules,
935
990
  pluginRulesByPrefix,
936
991
  observedRules,
992
+ observedRuleLevels,
937
993
  missingRules,
938
994
  observedOffRules,
939
995
  observedActiveRules,
@@ -1110,6 +1166,15 @@ function toPercent(value, total) {
1110
1166
  }
1111
1167
  return Number((value / total * 100).toFixed(1));
1112
1168
  }
1169
+ function countUniqueRules(ruleLists) {
1170
+ const unique = /* @__PURE__ */ new Set();
1171
+ for (const rules of ruleLists) {
1172
+ for (const rule of rules) {
1173
+ unique.add(rule);
1174
+ }
1175
+ }
1176
+ return unique.size;
1177
+ }
1113
1178
 
1114
1179
  // src/commands/check.ts
1115
1180
  var UPDATE_HINT = "Tip: when you intentionally accept changes, run `eslint-config-snapshot --update` to refresh the baseline.\n";
@@ -1730,6 +1795,7 @@ function createColorizer() {
1730
1795
  return {
1731
1796
  green: (text) => wrap("32", text),
1732
1797
  yellow: (text) => wrap("33", text),
1798
+ cyan: (text) => wrap("36", text),
1733
1799
  red: (text) => wrap("31", text),
1734
1800
  bold: (text) => wrap("1", text),
1735
1801
  dim: (text) => wrap("2", text)
@@ -1856,9 +1922,9 @@ function createProgram(cwd, terminal, onActionExit) {
1856
1922
  const format = opts.short ? "short" : opts.format;
1857
1923
  onActionExit(await executePrint(cwd, terminal, SNAPSHOT_DIR, format));
1858
1924
  });
1859
- program.command("catalog").description("Print discovered rule catalog and missing rules").option("--format <format>", "Output format: json|short", parsePrintFormat, "json").option("--short", "Alias for --format short").option("--missing", "Only print rules that are available but not observed in current snapshots").action(async (opts) => {
1925
+ program.command("catalog").description("Print discovered rule catalog and missing rules").option("--format <format>", "Output format: json|short", parsePrintFormat, "json").option("--short", "Alias for --format short").option("--detailed", "Show detailed rule lists grouped by error, warn, off, and unused").option("--missing", "Only print rules that are available but not observed in current snapshots").action(async (opts) => {
1860
1926
  const format = opts.short ? "short" : opts.format;
1861
- onActionExit(await executeCatalog(cwd, terminal, SNAPSHOT_DIR, format, Boolean(opts.missing)));
1927
+ onActionExit(await executeCatalog(cwd, terminal, SNAPSHOT_DIR, format, Boolean(opts.missing), Boolean(opts.detailed)));
1862
1928
  });
1863
1929
  program.command("catalog-check").description("Compare current catalog against stored catalog baseline").action(async () => {
1864
1930
  onActionExit(await executeCatalogCheck(cwd, terminal, SNAPSHOT_DIR));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eslint-config-snapshot/cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,6 +31,6 @@
31
31
  "commander": "^14.0.3",
32
32
  "debug": "^4.4.3",
33
33
  "fast-glob": "^3.3.3",
34
- "@eslint-config-snapshot/api": "1.3.0"
34
+ "@eslint-config-snapshot/api": "1.3.2"
35
35
  }
36
36
  }
@@ -18,6 +18,7 @@ type CatalogRow = {
18
18
  coreRules: string[]
19
19
  pluginRulesByPrefix: Record<string, string[]>
20
20
  observedRules: string[]
21
+ observedRuleLevels: Record<string, 'error' | 'warn' | 'off'>
21
22
  missingRules: string[]
22
23
  observedOffRules: string[]
23
24
  observedActiveRules: string[]
@@ -55,12 +56,13 @@ export async function executeCatalog(
55
56
  terminal: TerminalIO,
56
57
  snapshotDir: string,
57
58
  format: CatalogFormat,
58
- missingOnly: boolean
59
+ missingOnly: boolean,
60
+ detailed: boolean
59
61
  ): Promise<number> {
60
62
  const rows = await computeCatalogRows(cwd, terminal, snapshotDir, `catalog:${format}`, true)
61
63
 
62
64
  if (format === 'short') {
63
- terminal.write(formatShortCatalog(rows, missingOnly))
65
+ terminal.write(formatShortCatalog(rows, { missingOnly, detailed, color: terminal.colors }))
64
66
  return 0
65
67
  }
66
68
 
@@ -87,9 +89,11 @@ export async function executeCatalogUpdate(cwd: string, terminal: TerminalIO, sn
87
89
  await writeCatalogBaselineFiles(cwd, snapshotDir, rows)
88
90
 
89
91
  const groups = rows.length
90
- const available = rows.reduce((sum, row) => sum + row.totalStats.totalAvailable, 0)
91
- const inUse = rows.reduce((sum, row) => sum + row.totalStats.inUse, 0)
92
+ const available = countUniqueRules(rows.map((row) => row.availableRules))
93
+ const inUse = countUniqueRules(rows.map((row) => row.observedRules.filter((ruleName) => row.availableRules.includes(ruleName))))
92
94
  terminal.write(`🧪 Catalog baseline updated: ${groups} groups, ${available} available rules, ${inUse} currently in use.\n`)
95
+ terminal.section('📊 Catalog summary')
96
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }))
93
97
  return 0
94
98
  }
95
99
 
@@ -107,9 +111,13 @@ export async function executeCatalogCheck(cwd: string, terminal: TerminalIO, sna
107
111
  const diffs = compareCatalogBaselines(stored, current)
108
112
  if (diffs.length === 0) {
109
113
  terminal.write('Great news: no catalog drift detected.\n')
114
+ terminal.section('📊 Catalog summary')
115
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }))
110
116
  return 0
111
117
  }
112
118
 
119
+ terminal.section('📊 Catalog summary')
120
+ terminal.write(formatShortCatalog(rows, { missingOnly: false, detailed: false, color: terminal.colors }))
113
121
  terminal.write(`⚠️ Heads up: catalog drift detected in ${diffs.length} groups.\n`)
114
122
  for (const diff of diffs) {
115
123
  terminal.write(
@@ -163,6 +171,9 @@ async function computeCatalogRows(
163
171
  const availableRules = catalog?.allRules ?? []
164
172
  const availableRuleSet = new Set(availableRules)
165
173
  const missingRules = availableRules.filter((ruleName) => !snapshot.rules[ruleName])
174
+ const observedRuleLevels = Object.fromEntries(
175
+ observedRules.map((ruleName) => [ruleName, getPrimarySeverity(snapshot.rules[ruleName])])
176
+ ) as Record<string, 'error' | 'warn' | 'off'>
166
177
  const observedOffRules = observedRules.filter((ruleName) => isRuleOffOnly(snapshot.rules[ruleName]))
167
178
  const observedActiveRules = observedRules.filter((ruleName) => !isRuleOffOnly(snapshot.rules[ruleName]))
168
179
  const observedOutsideCatalog = observedRules.filter((ruleName) => !availableRuleSet.has(ruleName)).length
@@ -188,6 +199,7 @@ async function computeCatalogRows(
188
199
  coreRules,
189
200
  pluginRulesByPrefix,
190
201
  observedRules,
202
+ observedRuleLevels,
191
203
  missingRules,
192
204
  observedOffRules,
193
205
  observedActiveRules,
@@ -392,3 +404,13 @@ function toPercent(value: number, total: number): number {
392
404
  }
393
405
  return Number(((value / total) * 100).toFixed(1))
394
406
  }
407
+
408
+ function countUniqueRules(ruleLists: string[][]): number {
409
+ const unique = new Set<string>()
410
+ for (const rules of ruleLists) {
411
+ for (const rule of rules) {
412
+ unique.add(rule)
413
+ }
414
+ }
415
+ return unique.size
416
+ }
package/src/formatters.ts CHANGED
@@ -14,6 +14,7 @@ export type RuleCatalogLike = {
14
14
  coreRules: string[]
15
15
  pluginRulesByPrefix: Record<string, string[]>
16
16
  observedRules: string[]
17
+ observedRuleLevels: Record<string, 'error' | 'warn' | 'off'>
17
18
  missingRules: string[]
18
19
  observedOffRules: string[]
19
20
  observedActiveRules: string[]
@@ -21,6 +22,11 @@ export type RuleCatalogLike = {
21
22
  coreStats: UsageStats
22
23
  pluginStats: Array<{ pluginId: string } & UsageStats>
23
24
  }
25
+ type CatalogColorizer = {
26
+ green: (text: string) => string
27
+ yellow: (text: string) => string
28
+ cyan: (text: string) => string
29
+ }
24
30
 
25
31
  export type UsageStats = {
26
32
  totalAvailable: number
@@ -101,7 +107,7 @@ export function summarizeChanges(changes: Array<{ groupId: string; diff: Snapsho
101
107
  }
102
108
 
103
109
  export function summarizeSnapshots(snapshots: Map<string, SnapshotLike>) {
104
- const { rules, error, warn, off } = countRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules))
110
+ const { rules, error, warn, off } = countUniqueRuleSeverities([...snapshots.values()].map((snapshot) => snapshot.rules))
105
111
  return { groups: snapshots.size, rules, error, warn, off }
106
112
  }
107
113
 
@@ -211,7 +217,11 @@ export function formatShortConfig(payload: {
211
217
  return `${lines.join('\n')}\n`
212
218
  }
213
219
 
214
- export function formatShortCatalog(catalogs: RuleCatalogLike[], missingOnly: boolean): string {
220
+ export function formatShortCatalog(
221
+ catalogs: RuleCatalogLike[],
222
+ options: { missingOnly: boolean; detailed: boolean; color?: CatalogColorizer }
223
+ ): string {
224
+ const { missingOnly, detailed, color } = options
215
225
  const lines: string[] = []
216
226
  const sorted = [...catalogs].sort((a, b) => a.groupId.localeCompare(b.groupId))
217
227
  const showGroupHeader = sorted.length > 1 || sorted.some((catalog) => catalog.groupId !== 'default')
@@ -228,10 +238,8 @@ export function formatShortCatalog(catalogs: RuleCatalogLike[], missingOnly: boo
228
238
  lines.push(` - ${plugin.pluginId}: ${formatUsageLine(plugin)}`)
229
239
  }
230
240
 
231
- const detailRules = missingOnly ? catalog.missingRules : catalog.availableRules
232
- lines.push(`${missingOnly ? '🕳️ missing list' : '📚 available list'} (${detailRules.length}):`)
233
- for (const ruleName of detailRules) {
234
- lines.push(` - ${ruleName}`)
241
+ if (detailed) {
242
+ appendRuleStateGroups(lines, catalog, missingOnly, color)
235
243
  }
236
244
  if (showGroupHeader) {
237
245
  lines.push('')
@@ -247,6 +255,54 @@ function formatUsageLine(stats: UsageStats): string {
247
255
  return `${stats.inUse}/${stats.totalAvailable} in use (${stats.inUsePct}%) | error ${stats.error} | warn ${stats.warn} | off ${stats.off} | not used ${stats.missing}`
248
256
  }
249
257
 
258
+ function appendRuleStateGroups(
259
+ lines: string[],
260
+ catalog: RuleCatalogLike,
261
+ missingOnly: boolean,
262
+ color?: CatalogColorizer
263
+ ): void {
264
+ if (missingOnly) {
265
+ appendRuleList(lines, color?.yellow('🟡 unused') ?? '🟡 unused', catalog.missingRules)
266
+ return
267
+ }
268
+
269
+ const observedRuleSet = new Set(catalog.observedRules)
270
+ const errorRules: string[] = []
271
+ const warnRules: string[] = []
272
+ const offRules: string[] = []
273
+ const unusedRules: string[] = []
274
+
275
+ for (const ruleName of catalog.availableRules) {
276
+ if (!observedRuleSet.has(ruleName)) {
277
+ unusedRules.push(ruleName)
278
+ continue
279
+ }
280
+ const level = catalog.observedRuleLevels[ruleName]
281
+ if (level === 'off') {
282
+ offRules.push(ruleName)
283
+ continue
284
+ }
285
+ if (level === 'error') {
286
+ errorRules.push(ruleName)
287
+ continue
288
+ }
289
+ warnRules.push(ruleName)
290
+ }
291
+
292
+ appendRuleList(lines, color?.green('🟢 error') ?? '🟢 error', errorRules)
293
+ appendRuleList(lines, color?.green('🟢 warn') ?? '🟢 warn', warnRules)
294
+ appendRuleList(lines, color?.cyan('🔵 off') ?? '🔵 off', offRules)
295
+ appendRuleList(lines, color?.yellow('🟡 unused') ?? '🟡 unused', unusedRules)
296
+ }
297
+
298
+ function appendRuleList(lines: string[], label: string, rules: string[]): void {
299
+ lines.push(`${label} (${rules.length}):`)
300
+ const sortedRules = [...rules].sort((a, b) => a.localeCompare(b))
301
+ for (const ruleName of sortedRules) {
302
+ lines.push(` - ${ruleName}`)
303
+ }
304
+ }
305
+
250
306
  export function formatCommandDisplayLabel(commandLabel: string): string {
251
307
  switch (commandLabel) {
252
308
  case 'check':
@@ -331,6 +387,39 @@ export function countRuleSeverities(ruleObjects: RuleObject[]) {
331
387
  return { rules, error, warn, off }
332
388
  }
333
389
 
390
+ export function countUniqueRuleSeverities(ruleObjects: RuleObject[]) {
391
+ const severityRank: Record<'off' | 'warn' | 'error', number> = { off: 0, warn: 1, error: 2 }
392
+ const severityByRule = new Map<string, 'off' | 'warn' | 'error'>()
393
+
394
+ for (const rulesObject of ruleObjects) {
395
+ for (const [ruleName, entry] of Object.entries(rulesObject)) {
396
+ const nextSeverity = getPrimarySeverity(entry)
397
+ if (!nextSeverity) {
398
+ continue
399
+ }
400
+ const currentSeverity = severityByRule.get(ruleName)
401
+ if (!currentSeverity || severityRank[nextSeverity] > severityRank[currentSeverity]) {
402
+ severityByRule.set(ruleName, nextSeverity)
403
+ }
404
+ }
405
+ }
406
+
407
+ let error = 0
408
+ let warn = 0
409
+ let off = 0
410
+ for (const severity of severityByRule.values()) {
411
+ if (severity === 'error') {
412
+ error += 1
413
+ } else if (severity === 'warn') {
414
+ warn += 1
415
+ } else {
416
+ off += 1
417
+ }
418
+ }
419
+
420
+ return { rules: severityByRule.size, error, warn, off }
421
+ }
422
+
334
423
  function getPrimarySeverity(entry: SnapshotRuleEntry | undefined): 'off' | 'warn' | 'error' | undefined {
335
424
  if (!entry) {
336
425
  return undefined
package/src/index.ts CHANGED
@@ -181,10 +181,11 @@ function createProgram(cwd: string, terminal: TerminalIO, onActionExit: (code: n
181
181
  .description('Print discovered rule catalog and missing rules')
182
182
  .option('--format <format>', 'Output format: json|short', parsePrintFormat, 'json')
183
183
  .option('--short', 'Alias for --format short')
184
+ .option('--detailed', 'Show detailed rule lists grouped by error, warn, off, and unused')
184
185
  .option('--missing', 'Only print rules that are available but not observed in current snapshots')
185
- .action(async (opts: { format: CatalogFormat; short?: boolean; missing?: boolean }) => {
186
+ .action(async (opts: { format: CatalogFormat; short?: boolean; missing?: boolean; detailed?: boolean }) => {
186
187
  const format: CatalogFormat = opts.short ? 'short' : opts.format
187
- onActionExit(await executeCatalog(cwd, terminal, SNAPSHOT_DIR, format, Boolean(opts.missing)))
188
+ onActionExit(await executeCatalog(cwd, terminal, SNAPSHOT_DIR, format, Boolean(opts.missing), Boolean(opts.detailed)))
188
189
  })
189
190
 
190
191
  program
package/src/terminal.ts CHANGED
@@ -3,6 +3,7 @@ import { createInterface } from 'node:readline'
3
3
  type Colorizer = {
4
4
  green: (text: string) => string
5
5
  yellow: (text: string) => string
6
+ cyan: (text: string) => string
6
7
  red: (text: string) => string
7
8
  bold: (text: string) => string
8
9
  dim: (text: string) => string
@@ -171,6 +172,7 @@ function createColorizer(): Colorizer {
171
172
  return {
172
173
  green: (text: string) => wrap('32', text),
173
174
  yellow: (text: string) => wrap('33', text),
175
+ cyan: (text: string) => wrap('36', text),
174
176
  red: (text: string) => wrap('31', text),
175
177
  bold: (text: string) => wrap('1', text),
176
178
  dim: (text: string) => wrap('2', text)
@@ -238,10 +238,19 @@ no-debugger: off
238
238
  expect(output).toContain('🧱 core: 2/3 in use')
239
239
  expect(output).toContain('🔌 plugins tracked: 1')
240
240
  expect(output).toContain(' - alpha: 0/2 in use')
241
- expect(output).toContain('🕳️ missing list (3):')
242
- expect(output).toContain('alpha/observed')
241
+ expect(output).not.toContain('🟡 unused')
242
+ })
243
+
244
+ it('catalog --short --detailed prints grouped detailed lists', async () => {
245
+ const writeSpy = vi.spyOn(process.stdout, 'write')
246
+ const code = await runCli('catalog', fixtureRoot, ['--short', '--detailed'])
247
+ expect(code).toBe(0)
248
+ const output = String(writeSpy.mock.calls.at(-1)?.[0] ?? '')
249
+ expect(output).toContain('🟢 error')
250
+ expect(output).toContain('🟢 warn')
251
+ expect(output).toContain('🔵 off')
252
+ expect(output).toContain('🟡 unused')
243
253
  expect(output).toContain('alpha/only-in-catalog')
244
- expect(output).toContain('no-alert')
245
254
  })
246
255
 
247
256
  it('init creates scaffold config file when target=file', async () => {
@@ -255,10 +255,18 @@ no-debugger: off
255
255
  expect(result.stdout).toContain('🧱 core: 2/3 in use')
256
256
  expect(result.stdout).toContain('🔌 plugins tracked: 1')
257
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')
258
+ expect(result.stdout).not.toContain('🟡 unused')
259
+ expect(result.stderr).toBe('')
260
+ })
261
+
262
+ it('catalog --short --detailed returns grouped state lists', () => {
263
+ const result = run(['catalog', '--short', '--detailed'])
264
+ expect(result.status).toBe(0)
265
+ expect(result.stdout).toContain('🟢 error')
266
+ expect(result.stdout).toContain('🟢 warn')
267
+ expect(result.stdout).toContain('🔵 off')
268
+ expect(result.stdout).toContain('🟡 unused')
260
269
  expect(result.stdout).toContain('alpha/only-in-catalog')
261
- expect(result.stdout).toContain('no-alert')
262
270
  expect(result.stderr).toBe('')
263
271
  })
264
272
 
@@ -266,11 +274,15 @@ no-debugger: off
266
274
  const update = run(['catalog-update'])
267
275
  expect(update.status).toBe(0)
268
276
  expect(update.stdout).toContain('Catalog baseline updated:')
277
+ expect(update.stdout).toContain('📦 total:')
278
+ expect(update.stdout).toContain('🔌 plugins tracked:')
269
279
  expect(update.stderr).toBe('')
270
280
 
271
281
  const check = run(['catalog-check'])
272
282
  expect(check.status).toBe(0)
273
- expect(check.stdout).toBe('Great news: no catalog drift detected.\n')
283
+ expect(check.stdout).toContain('Great news: no catalog drift detected.')
284
+ expect(check.stdout).toContain('📦 total:')
285
+ expect(check.stdout).toContain('🔌 plugins tracked:')
274
286
  expect(check.stderr).toBe('')
275
287
  })
276
288
 
@@ -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
  })