@eslint-config-snapshot/api 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @eslint-config-snapshot/api
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Minor release with improved CLI output consistency and faster workspace extraction using ESLint API fallback strategy.
8
+
9
+ ## 0.3.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Fix deterministic aggregation when same-severity ESLint rule options differ across sampled files, preventing update crashes.
14
+
15
+ ## 0.3.1
16
+
17
+ ### Patch Changes
18
+
19
+ - Release patch bump after init UX clarity improvements for default catch-all group messaging.
20
+
3
21
  ## 0.3.0
4
22
 
5
23
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  compareSeverity: () => compareSeverity,
39
39
  diffSnapshots: () => diffSnapshots,
40
40
  discoverWorkspaces: () => discoverWorkspaces,
41
+ extractRulesForWorkspaceSamples: () => extractRulesForWorkspaceSamples,
41
42
  extractRulesFromPrintConfig: () => extractRulesFromPrintConfig,
42
43
  findConfigPath: () => findConfigPath,
43
44
  getConfigScaffold: () => getConfigScaffold,
@@ -288,6 +289,7 @@ var import_node_child_process = require("child_process");
288
289
  var import_node_fs = require("fs");
289
290
  var import_node_module = require("module");
290
291
  var import_node_path2 = __toESM(require("path"), 1);
292
+ var import_node_url = require("url");
291
293
  function resolveEslintBinForWorkspace(workspaceAbs) {
292
294
  const anchor = import_node_path2.default.join(workspaceAbs, "__snapshot_anchor__.cjs");
293
295
  const req = (0, import_node_module.createRequire)(anchor);
@@ -352,6 +354,44 @@ function extractRulesFromPrintConfig(workspaceAbs, fileAbs) {
352
354
  throw new Error(`Invalid JSON from eslint --print-config for ${fileAbs}`);
353
355
  }
354
356
  const rules = parsed.rules ?? {};
357
+ return normalizeRules(rules);
358
+ }
359
+ async function extractRulesForWorkspaceSamples(workspaceAbs, fileAbsList) {
360
+ const evaluate = await createWorkspaceEvaluator(workspaceAbs);
361
+ const results = [];
362
+ for (const fileAbs of fileAbsList) {
363
+ try {
364
+ const rules = await evaluate(fileAbs);
365
+ results.push({ fileAbs, rules });
366
+ } catch (error) {
367
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
368
+ results.push({ fileAbs, error: normalizedError });
369
+ }
370
+ }
371
+ return results;
372
+ }
373
+ async function createWorkspaceEvaluator(workspaceAbs) {
374
+ try {
375
+ const anchor = import_node_path2.default.join(workspaceAbs, "__snapshot_anchor__.cjs");
376
+ const req = (0, import_node_module.createRequire)(anchor);
377
+ const eslintModuleEntry = req.resolve("eslint");
378
+ const eslintModule = await import((0, import_node_url.pathToFileURL)(eslintModuleEntry).href);
379
+ const ESLintClass = eslintModule.ESLint ?? eslintModule.default?.ESLint;
380
+ if (ESLintClass) {
381
+ const eslint = new ESLintClass({ cwd: workspaceAbs });
382
+ return async (fileAbs) => {
383
+ const config = await eslint.calculateConfigForFile(fileAbs);
384
+ if (!config || typeof config !== "object") {
385
+ throw new Error(`Empty ESLint print-config output for ${fileAbs}`);
386
+ }
387
+ return normalizeRules(config.rules ?? {});
388
+ };
389
+ }
390
+ } catch {
391
+ }
392
+ return (fileAbs) => Promise.resolve(extractRulesFromPrintConfig(workspaceAbs, fileAbs));
393
+ }
394
+ function normalizeRules(rules) {
355
395
  const normalized = /* @__PURE__ */ new Map();
356
396
  for (const [ruleName, ruleConfig] of Object.entries(rules)) {
357
397
  normalized.set(ruleName, normalizeRuleEntry(ruleConfig));
@@ -398,8 +438,20 @@ function aggregateRules(ruleMaps) {
398
438
  }
399
439
  const currentOptions = currentEntry.length > 1 ? canonicalizeJson(currentEntry[1]) : void 0;
400
440
  const nextOptions = nextEntry.length > 1 ? canonicalizeJson(nextEntry[1]) : void 0;
401
- if (JSON.stringify(currentOptions) !== JSON.stringify(nextOptions)) {
402
- throw new Error(`Conflicting rule options for ${ruleName} at severity ${currentEntry[0]}`);
441
+ if (currentOptions === void 0 && nextOptions !== void 0) {
442
+ aggregated.set(ruleName, canonicalizeJson(nextEntry));
443
+ continue;
444
+ }
445
+ if (currentOptions !== void 0 && nextOptions === void 0) {
446
+ continue;
447
+ }
448
+ if (currentOptions === void 0 && nextOptions === void 0) {
449
+ continue;
450
+ }
451
+ const currentJson = JSON.stringify(currentOptions);
452
+ const nextJson = JSON.stringify(nextOptions);
453
+ if (nextJson < currentJson) {
454
+ aggregated.set(ruleName, canonicalizeJson(nextEntry));
403
455
  }
404
456
  }
405
457
  }
@@ -595,6 +647,7 @@ function getConfigScaffold(preset = "minimal") {
595
647
  compareSeverity,
596
648
  diffSnapshots,
597
649
  discoverWorkspaces,
650
+ extractRulesForWorkspaceSamples,
598
651
  extractRulesFromPrintConfig,
599
652
  findConfigPath,
600
653
  getConfigScaffold,
package/dist/index.js CHANGED
@@ -233,6 +233,7 @@ import { spawnSync } from "child_process";
233
233
  import { existsSync, readFileSync } from "fs";
234
234
  import { createRequire } from "module";
235
235
  import path2 from "path";
236
+ import { pathToFileURL } from "url";
236
237
  function resolveEslintBinForWorkspace(workspaceAbs) {
237
238
  const anchor = path2.join(workspaceAbs, "__snapshot_anchor__.cjs");
238
239
  const req = createRequire(anchor);
@@ -297,6 +298,44 @@ function extractRulesFromPrintConfig(workspaceAbs, fileAbs) {
297
298
  throw new Error(`Invalid JSON from eslint --print-config for ${fileAbs}`);
298
299
  }
299
300
  const rules = parsed.rules ?? {};
301
+ return normalizeRules(rules);
302
+ }
303
+ async function extractRulesForWorkspaceSamples(workspaceAbs, fileAbsList) {
304
+ const evaluate = await createWorkspaceEvaluator(workspaceAbs);
305
+ const results = [];
306
+ for (const fileAbs of fileAbsList) {
307
+ try {
308
+ const rules = await evaluate(fileAbs);
309
+ results.push({ fileAbs, rules });
310
+ } catch (error) {
311
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
312
+ results.push({ fileAbs, error: normalizedError });
313
+ }
314
+ }
315
+ return results;
316
+ }
317
+ async function createWorkspaceEvaluator(workspaceAbs) {
318
+ try {
319
+ const anchor = path2.join(workspaceAbs, "__snapshot_anchor__.cjs");
320
+ const req = createRequire(anchor);
321
+ const eslintModuleEntry = req.resolve("eslint");
322
+ const eslintModule = await import(pathToFileURL(eslintModuleEntry).href);
323
+ const ESLintClass = eslintModule.ESLint ?? eslintModule.default?.ESLint;
324
+ if (ESLintClass) {
325
+ const eslint = new ESLintClass({ cwd: workspaceAbs });
326
+ return async (fileAbs) => {
327
+ const config = await eslint.calculateConfigForFile(fileAbs);
328
+ if (!config || typeof config !== "object") {
329
+ throw new Error(`Empty ESLint print-config output for ${fileAbs}`);
330
+ }
331
+ return normalizeRules(config.rules ?? {});
332
+ };
333
+ }
334
+ } catch {
335
+ }
336
+ return (fileAbs) => Promise.resolve(extractRulesFromPrintConfig(workspaceAbs, fileAbs));
337
+ }
338
+ function normalizeRules(rules) {
300
339
  const normalized = /* @__PURE__ */ new Map();
301
340
  for (const [ruleName, ruleConfig] of Object.entries(rules)) {
302
341
  normalized.set(ruleName, normalizeRuleEntry(ruleConfig));
@@ -343,8 +382,20 @@ function aggregateRules(ruleMaps) {
343
382
  }
344
383
  const currentOptions = currentEntry.length > 1 ? canonicalizeJson(currentEntry[1]) : void 0;
345
384
  const nextOptions = nextEntry.length > 1 ? canonicalizeJson(nextEntry[1]) : void 0;
346
- if (JSON.stringify(currentOptions) !== JSON.stringify(nextOptions)) {
347
- throw new Error(`Conflicting rule options for ${ruleName} at severity ${currentEntry[0]}`);
385
+ if (currentOptions === void 0 && nextOptions !== void 0) {
386
+ aggregated.set(ruleName, canonicalizeJson(nextEntry));
387
+ continue;
388
+ }
389
+ if (currentOptions !== void 0 && nextOptions === void 0) {
390
+ continue;
391
+ }
392
+ if (currentOptions === void 0 && nextOptions === void 0) {
393
+ continue;
394
+ }
395
+ const currentJson = JSON.stringify(currentOptions);
396
+ const nextJson = JSON.stringify(nextOptions);
397
+ if (nextJson < currentJson) {
398
+ aggregated.set(ruleName, canonicalizeJson(nextEntry));
348
399
  }
349
400
  }
350
401
  }
@@ -539,6 +590,7 @@ export {
539
590
  compareSeverity,
540
591
  diffSnapshots,
541
592
  discoverWorkspaces,
593
+ extractRulesForWorkspaceSamples,
542
594
  extractRulesFromPrintConfig,
543
595
  findConfigPath,
544
596
  getConfigScaffold,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eslint-config-snapshot/api",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/extract.ts CHANGED
@@ -2,12 +2,14 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { createRequire } from 'node:module'
4
4
  import path from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
5
6
 
6
7
  import { canonicalizeJson, normalizeSeverity } from './core.js'
7
8
 
8
9
  export type NormalizedRuleEntry = [severity: 'off' | 'warn' | 'error'] | [severity: 'off' | 'warn' | 'error', options: unknown]
9
10
 
10
11
  export type ExtractedWorkspaceRules = Map<string, NormalizedRuleEntry>
12
+ export type WorkspaceExtractionResult = { fileAbs: string; rules?: ExtractedWorkspaceRules; error?: Error }
11
13
 
12
14
  export function resolveEslintBinForWorkspace(workspaceAbs: string): string {
13
15
  const anchor = path.join(workspaceAbs, '__snapshot_anchor__.cjs')
@@ -86,6 +88,61 @@ export function extractRulesFromPrintConfig(workspaceAbs: string, fileAbs: strin
86
88
  }
87
89
 
88
90
  const rules = (parsed as { rules?: Record<string, unknown> }).rules ?? {}
91
+ return normalizeRules(rules)
92
+ }
93
+
94
+ export async function extractRulesForWorkspaceSamples(
95
+ workspaceAbs: string,
96
+ fileAbsList: string[]
97
+ ): Promise<WorkspaceExtractionResult[]> {
98
+ const evaluate = await createWorkspaceEvaluator(workspaceAbs)
99
+ const results: WorkspaceExtractionResult[] = []
100
+
101
+ for (const fileAbs of fileAbsList) {
102
+ try {
103
+ const rules = await evaluate(fileAbs)
104
+ results.push({ fileAbs, rules })
105
+ } catch (error: unknown) {
106
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
107
+ results.push({ fileAbs, error: normalizedError })
108
+ }
109
+ }
110
+
111
+ return results
112
+ }
113
+
114
+ async function createWorkspaceEvaluator(
115
+ workspaceAbs: string
116
+ ): Promise<(fileAbs: string) => Promise<ExtractedWorkspaceRules>> {
117
+ try {
118
+ const anchor = path.join(workspaceAbs, '__snapshot_anchor__.cjs')
119
+ const req = createRequire(anchor)
120
+ const eslintModuleEntry = req.resolve('eslint')
121
+ const eslintModule = (await import(pathToFileURL(eslintModuleEntry).href)) as {
122
+ ESLint?: new (options: { cwd: string }) => { calculateConfigForFile: (fileAbs: string) => Promise<{ rules?: Record<string, unknown> } | undefined> }
123
+ default?: { ESLint?: new (options: { cwd: string }) => { calculateConfigForFile: (fileAbs: string) => Promise<{ rules?: Record<string, unknown> } | undefined> } }
124
+ }
125
+
126
+ const ESLintClass = eslintModule.ESLint ?? eslintModule.default?.ESLint
127
+ if (ESLintClass) {
128
+ const eslint = new ESLintClass({ cwd: workspaceAbs })
129
+ return async (fileAbs: string) => {
130
+ const config = await eslint.calculateConfigForFile(fileAbs)
131
+ if (!config || typeof config !== 'object') {
132
+ throw new Error(`Empty ESLint print-config output for ${fileAbs}`)
133
+ }
134
+
135
+ return normalizeRules(config.rules ?? {})
136
+ }
137
+ }
138
+ } catch {
139
+ // fall through to subprocess-based extractor
140
+ }
141
+
142
+ return (fileAbs: string) => Promise.resolve(extractRulesFromPrintConfig(workspaceAbs, fileAbs))
143
+ }
144
+
145
+ function normalizeRules(rules: Record<string, unknown>): ExtractedWorkspaceRules {
89
146
  const normalized = new Map<string, NormalizedRuleEntry>()
90
147
 
91
148
  for (const [ruleName, ruleConfig] of Object.entries(rules)) {
package/src/index.ts CHANGED
@@ -7,8 +7,8 @@ export type { GroupAssignment, GroupDefinition, WorkspaceDiscovery, WorkspaceInp
7
7
  export { sampleWorkspaceFiles } from './sampling.js'
8
8
  export type { SamplingConfig } from './sampling.js'
9
9
 
10
- export { extractRulesFromPrintConfig, resolveEslintBinForWorkspace } from './extract.js'
11
- export type { ExtractedWorkspaceRules, NormalizedRuleEntry } from './extract.js'
10
+ export { extractRulesForWorkspaceSamples, extractRulesFromPrintConfig, resolveEslintBinForWorkspace } from './extract.js'
11
+ export type { ExtractedWorkspaceRules, NormalizedRuleEntry, WorkspaceExtractionResult } from './extract.js'
12
12
 
13
13
  export { aggregateRules, buildSnapshot, readSnapshotFile, writeSnapshotFile } from './snapshot.js'
14
14
  export type { SnapshotFile } from './snapshot.js'
package/src/snapshot.ts CHANGED
@@ -36,8 +36,23 @@ export function aggregateRules(ruleMaps: readonly Map<string, NormalizedRuleEntr
36
36
  const currentOptions = currentEntry.length > 1 ? canonicalizeJson(currentEntry[1]) : undefined
37
37
  const nextOptions = nextEntry.length > 1 ? canonicalizeJson(nextEntry[1]) : undefined
38
38
 
39
- if (JSON.stringify(currentOptions) !== JSON.stringify(nextOptions)) {
40
- throw new Error(`Conflicting rule options for ${ruleName} at severity ${currentEntry[0]}`)
39
+ if (currentOptions === undefined && nextOptions !== undefined) {
40
+ aggregated.set(ruleName, canonicalizeJson(nextEntry))
41
+ continue
42
+ }
43
+
44
+ if (currentOptions !== undefined && nextOptions === undefined) {
45
+ continue
46
+ }
47
+
48
+ if (currentOptions === undefined && nextOptions === undefined) {
49
+ continue
50
+ }
51
+
52
+ const currentJson = JSON.stringify(currentOptions)
53
+ const nextJson = JSON.stringify(nextOptions)
54
+ if (nextJson < currentJson) {
55
+ aggregated.set(ruleName, canonicalizeJson(nextEntry))
41
56
  }
42
57
  }
43
58
  }
@@ -3,7 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { afterAll, describe, expect, it } from 'vitest'
5
5
 
6
- import { extractRulesFromPrintConfig, resolveEslintBinForWorkspace } from '../src/index.js'
6
+ import { extractRulesForWorkspaceSamples, extractRulesFromPrintConfig, resolveEslintBinForWorkspace } from '../src/index.js'
7
7
 
8
8
  const workspace = path.join(os.tmpdir(), `snapshot-extract-${Date.now()}`)
9
9
 
@@ -137,4 +137,29 @@ describe('extract', () => {
137
137
  `Failed to run eslint --print-config for ${fileAbs}`
138
138
  )
139
139
  })
140
+
141
+ it('extracts multiple sampled files in one workspace call', async () => {
142
+ const multiWorkspace = `${workspace}-multi`
143
+ await mkdir(path.join(multiWorkspace, 'node_modules/eslint/bin'), { recursive: true })
144
+ await mkdir(path.join(multiWorkspace, 'src'), { recursive: true })
145
+ await writeFile(path.join(multiWorkspace, 'node_modules/eslint/package.json'), JSON.stringify({ name: 'eslint', version: '0.0.0' }, null, 2))
146
+ await writeFile(
147
+ path.join(multiWorkspace, 'node_modules/eslint/bin/eslint.js'),
148
+ "console.log(JSON.stringify({ rules: { 'no-console': 1 } }))\n"
149
+ )
150
+
151
+ const fileA = path.join(multiWorkspace, 'src/a.ts')
152
+ const fileB = path.join(multiWorkspace, 'src/b.ts')
153
+ await writeFile(fileA, 'export const a = 1\n')
154
+ await writeFile(fileB, 'export const b = 1\n')
155
+
156
+ const extracted = await extractRulesForWorkspaceSamples(multiWorkspace, [fileA, fileB])
157
+ expect(extracted).toHaveLength(2)
158
+ for (const entry of extracted) {
159
+ expect(entry.error).toBeUndefined()
160
+ expect(entry.rules ? Object.fromEntries(entry.rules.entries()) : {}).toEqual({
161
+ 'no-console': ['warn']
162
+ })
163
+ }
164
+ })
140
165
  })
@@ -53,12 +53,25 @@ describe('snapshot', () => {
53
53
  })
54
54
  })
55
55
 
56
- it('throws on conflicting options at same severity', () => {
57
- expect(() =>
58
- aggregateRules([
59
- new Map([['no-restricted-imports', ['error', { paths: ['a'] }] as const]]),
60
- new Map([['no-restricted-imports', ['error', { paths: ['b'] }] as const]])
61
- ])
62
- ).toThrow('Conflicting rule options for no-restricted-imports at severity error')
56
+ it('resolves conflicting options at same severity deterministically', () => {
57
+ const result = aggregateRules([
58
+ new Map([['no-restricted-imports', ['error', { paths: ['b'] }] as const]]),
59
+ new Map([['no-restricted-imports', ['error', { paths: ['a'] }] as const]])
60
+ ])
61
+
62
+ expect(Object.fromEntries(result.entries())).toEqual({
63
+ 'no-restricted-imports': ['error', { paths: ['a'] }]
64
+ })
65
+ })
66
+
67
+ it('prefers configured options over bare severity at same level', () => {
68
+ const result = aggregateRules([
69
+ new Map([['@typescript-eslint/consistent-type-imports', ['warn'] as const]]),
70
+ new Map([['@typescript-eslint/consistent-type-imports', ['warn', { fixStyle: 'inline-type-imports' }] as const]])
71
+ ])
72
+
73
+ expect(Object.fromEntries(result.entries())).toEqual({
74
+ '@typescript-eslint/consistent-type-imports': ['warn', { fixStyle: 'inline-type-imports' }]
75
+ })
63
76
  })
64
77
  })