@eslint-config-snapshot/api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { cosmiconfig } from 'cosmiconfig'
2
+ import path from 'node:path'
3
+
4
+
5
+ export type SnapshotConfig = {
6
+ workspaceInput:
7
+ | {
8
+ mode: 'discover'
9
+ }
10
+ | {
11
+ mode: 'manual'
12
+ rootAbs?: string
13
+ workspaces: string[]
14
+ }
15
+ grouping: {
16
+ mode: 'match' | 'standalone'
17
+ allowEmptyGroups?: boolean
18
+ groups?: Array<{
19
+ name: string
20
+ match: string[]
21
+ }>
22
+ }
23
+ sampling: {
24
+ maxFilesPerWorkspace: number
25
+ includeGlobs: string[]
26
+ excludeGlobs: string[]
27
+ hintGlobs: string[]
28
+ }
29
+ }
30
+
31
+ export const DEFAULT_CONFIG: SnapshotConfig = {
32
+ workspaceInput: { mode: 'discover' },
33
+ grouping: {
34
+ mode: 'match',
35
+ groups: [{ name: 'default', match: ['**/*'] }]
36
+ },
37
+ sampling: {
38
+ maxFilesPerWorkspace: 8,
39
+ includeGlobs: ['**/*.{js,jsx,ts,tsx,cjs,mjs}'],
40
+ excludeGlobs: ['**/node_modules/**', '**/dist/**'],
41
+ hintGlobs: []
42
+ }
43
+ }
44
+
45
+ const SPEC_SEARCH_PLACES = [
46
+ '.eslint-config-snapshot.js',
47
+ '.eslint-config-snapshot.cjs',
48
+ '.eslint-config-snapshot.mjs',
49
+ 'eslint-config-snapshot.config.js',
50
+ 'eslint-config-snapshot.config.cjs',
51
+ 'eslint-config-snapshot.config.mjs',
52
+ 'package.json',
53
+ '.eslint-config-snapshotrc',
54
+ '.eslint-config-snapshotrc.json',
55
+ '.eslint-config-snapshotrc.yaml',
56
+ '.eslint-config-snapshotrc.yml',
57
+ '.eslint-config-snapshotrc.js',
58
+ '.eslint-config-snapshotrc.cjs',
59
+ '.eslint-config-snapshotrc.mjs'
60
+ ]
61
+
62
+ export async function loadConfig(cwd?: string): Promise<SnapshotConfig> {
63
+ const found = await findConfigPath(cwd)
64
+ if (!found) {
65
+ return DEFAULT_CONFIG
66
+ }
67
+
68
+ return found.config
69
+ }
70
+
71
+ export async function findConfigPath(
72
+ cwd?: string
73
+ ): Promise<{ path: string; config: SnapshotConfig } | null> {
74
+ const root = path.resolve(cwd ?? process.cwd())
75
+ const explorer = cosmiconfig('eslint-config-snapshot', {
76
+ searchPlaces: SPEC_SEARCH_PLACES,
77
+ stopDir: root
78
+ })
79
+
80
+ const result = await explorer.search(root)
81
+ if (!result) {
82
+ return null
83
+ }
84
+
85
+ const maybeConfig = await loadUserConfig(result.config)
86
+
87
+ const config: SnapshotConfig = {
88
+ ...DEFAULT_CONFIG,
89
+ ...maybeConfig,
90
+ grouping: {
91
+ ...DEFAULT_CONFIG.grouping,
92
+ ...maybeConfig.grouping
93
+ },
94
+ sampling: {
95
+ ...DEFAULT_CONFIG.sampling,
96
+ ...maybeConfig.sampling
97
+ }
98
+ }
99
+
100
+ return {
101
+ path: result.filepath,
102
+ config
103
+ }
104
+ }
105
+
106
+ async function loadUserConfig(rawConfig: unknown): Promise<Partial<SnapshotConfig>> {
107
+ const resolved = typeof rawConfig === 'function' ? await rawConfig() : rawConfig
108
+ if (resolved === null || resolved === undefined) {
109
+ return {}
110
+ }
111
+
112
+ if (typeof resolved !== 'object' || Array.isArray(resolved)) {
113
+ throw new TypeError('Invalid config export: expected object, function, or async function returning an object')
114
+ }
115
+
116
+ return resolved as Partial<SnapshotConfig>
117
+ }
118
+
119
+ export type ConfigPreset = 'minimal' | 'full'
120
+
121
+ export function getConfigScaffold(preset: ConfigPreset = 'minimal'): string {
122
+ if (preset === 'minimal') {
123
+ return 'export default {}\n'
124
+ }
125
+
126
+ return `export default {
127
+ workspaceInput: { mode: 'discover' },
128
+ grouping: {
129
+ mode: 'match',
130
+ groups: [{ name: 'default', match: ['**/*'] }]
131
+ },
132
+ sampling: {
133
+ maxFilesPerWorkspace: 8,
134
+ includeGlobs: ['**/*.{js,jsx,ts,tsx,cjs,mjs}'],
135
+ excludeGlobs: ['**/node_modules/**', '**/dist/**'],
136
+ hintGlobs: []
137
+ }
138
+ }\n`
139
+ }
package/src/core.ts ADDED
@@ -0,0 +1,55 @@
1
+ export type JsonPrimitive = null | boolean | number | string
2
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
3
+
4
+ export function normalizePath(input: string): string {
5
+ const withSlashes = input.replaceAll('\\', '/')
6
+ const collapsed = withSlashes.replaceAll(/\/+/g, '/')
7
+ const withoutTrailing = collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed
8
+ return withoutTrailing === '' ? '.' : withoutTrailing
9
+ }
10
+
11
+ export function sortUnique(list: readonly string[]): string[] {
12
+ return [...new Set(list.map((item) => normalizePath(item)))].sort()
13
+ }
14
+
15
+ export function canonicalizeJson<T>(value: T): T {
16
+ if (value === null || value === undefined) {
17
+ return value
18
+ }
19
+
20
+ if (Array.isArray(value)) {
21
+ return value.map((entry) => canonicalizeJson(entry)) as T
22
+ }
23
+
24
+ if (typeof value === 'object') {
25
+ const record = value as Record<string, unknown>
26
+ const result: Record<string, unknown> = {}
27
+ for (const key of Object.keys(record).sort()) {
28
+ const entry = record[key]
29
+ if (entry !== undefined) {
30
+ result[key] = canonicalizeJson(entry)
31
+ }
32
+ }
33
+ return result as T
34
+ }
35
+
36
+ return value
37
+ }
38
+
39
+ export function compareSeverity(a: string, b: string): number {
40
+ const rank: Record<string, number> = { off: 0, warn: 1, error: 2 }
41
+ return rank[a] - rank[b]
42
+ }
43
+
44
+ export function normalizeSeverity(value: unknown): 'off' | 'warn' | 'error' {
45
+ if (value === 0 || value === 'off') {
46
+ return 'off'
47
+ }
48
+ if (value === 1 || value === 'warn') {
49
+ return 'warn'
50
+ }
51
+ if (value === 2 || value === 'error') {
52
+ return 'error'
53
+ }
54
+ throw new Error(`Unsupported severity: ${String(value)}`)
55
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { canonicalizeJson, sortUnique } from './core.js'
2
+
3
+ import type { SnapshotFile } from './snapshot.js'
4
+
5
+ export type RuleSeverityChange = {
6
+ rule: string
7
+ before: string
8
+ after: string
9
+ }
10
+
11
+ export type RuleOptionChange = {
12
+ rule: string
13
+ before: unknown
14
+ after: unknown
15
+ }
16
+
17
+ export type WorkspaceMembershipChange = {
18
+ added: string[]
19
+ removed: string[]
20
+ }
21
+
22
+ export type SnapshotDiff = {
23
+ introducedRules: string[]
24
+ removedRules: string[]
25
+ severityChanges: RuleSeverityChange[]
26
+ optionChanges: RuleOptionChange[]
27
+ workspaceMembershipChanges: WorkspaceMembershipChange
28
+ }
29
+
30
+ export function diffSnapshots(before: SnapshotFile, after: SnapshotFile): SnapshotDiff {
31
+ const beforeRules = before.rules
32
+ const afterRules = after.rules
33
+
34
+ const beforeNames = Object.keys(beforeRules).sort()
35
+ const afterNames = Object.keys(afterRules).sort()
36
+
37
+ const introducedRules = afterNames.filter((name) => !beforeNames.includes(name))
38
+ const removedRules = beforeNames.filter((name) => !afterNames.includes(name))
39
+
40
+ const severityChanges: RuleSeverityChange[] = []
41
+ const optionChanges: RuleOptionChange[] = []
42
+
43
+ for (const name of beforeNames.filter((entry) => afterNames.includes(entry))) {
44
+ const oldEntry = beforeRules[name]
45
+ const newEntry = afterRules[name]
46
+
47
+ if (oldEntry[0] !== newEntry[0]) {
48
+ severityChanges.push({
49
+ rule: name,
50
+ before: oldEntry[0],
51
+ after: newEntry[0]
52
+ })
53
+ }
54
+
55
+ const oldOptions = oldEntry.length > 1 ? canonicalizeJson(oldEntry[1]) : undefined
56
+ const newOptions = newEntry.length > 1 ? canonicalizeJson(newEntry[1]) : undefined
57
+
58
+ if (oldEntry[0] === 'off' || newEntry[0] === 'off') {
59
+ // Treat off->off option removal/addition as removed/introduced config intent.
60
+ if (oldEntry[0] === 'off' && newEntry[0] === 'off') {
61
+ if (oldOptions !== undefined && newOptions === undefined) {
62
+ removedRules.push(name)
63
+ } else if (oldOptions === undefined && newOptions !== undefined) {
64
+ introducedRules.push(name)
65
+ }
66
+ }
67
+ continue
68
+ }
69
+
70
+ if (JSON.stringify(oldOptions) !== JSON.stringify(newOptions)) {
71
+ optionChanges.push({
72
+ rule: name,
73
+ before: oldOptions,
74
+ after: newOptions
75
+ })
76
+ }
77
+ }
78
+
79
+ const beforeWorkspaces = sortUnique(before.workspaces)
80
+ const afterWorkspaces = sortUnique(after.workspaces)
81
+
82
+ return {
83
+ introducedRules: sortUnique(introducedRules),
84
+ removedRules: sortUnique(removedRules),
85
+ severityChanges,
86
+ optionChanges,
87
+ workspaceMembershipChanges: {
88
+ added: afterWorkspaces.filter((ws) => !beforeWorkspaces.includes(ws)),
89
+ removed: beforeWorkspaces.filter((ws) => !afterWorkspaces.includes(ws))
90
+ }
91
+ }
92
+ }
93
+
94
+ export function hasDiff(diff: SnapshotDiff): boolean {
95
+ return (
96
+ diff.introducedRules.length > 0 ||
97
+ diff.removedRules.length > 0 ||
98
+ diff.severityChanges.length > 0 ||
99
+ diff.optionChanges.length > 0 ||
100
+ diff.workspaceMembershipChanges.added.length > 0 ||
101
+ diff.workspaceMembershipChanges.removed.length > 0
102
+ )
103
+ }
package/src/extract.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import path from 'node:path'
5
+
6
+ import { canonicalizeJson, normalizeSeverity } from './core.js'
7
+
8
+ export type NormalizedRuleEntry = [severity: 'off' | 'warn' | 'error'] | [severity: 'off' | 'warn' | 'error', options: unknown]
9
+
10
+ export type ExtractedWorkspaceRules = Map<string, NormalizedRuleEntry>
11
+
12
+ export function resolveEslintBinForWorkspace(workspaceAbs: string): string {
13
+ const anchor = path.join(workspaceAbs, '__snapshot_anchor__.cjs')
14
+ const req = createRequire(anchor)
15
+ try {
16
+ return req.resolve('eslint/bin/eslint.js')
17
+ } catch {
18
+ try {
19
+ const eslintEntry = req.resolve('eslint')
20
+ const eslintRoot = findPackageRoot(eslintEntry)
21
+ const packageJsonPath = path.join(eslintRoot, 'package.json')
22
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { bin?: string | Record<string, string> }
23
+ const relativeBin = resolveBinPath(packageJson.bin)
24
+ const binAbs = path.resolve(eslintRoot, relativeBin)
25
+
26
+ if (existsSync(binAbs)) {
27
+ return binAbs
28
+ }
29
+ } catch {
30
+ // ignore fallback errors and throw deterministic workspace-scoped message below
31
+ }
32
+
33
+ throw new Error(`Unable to resolve eslint from workspace: ${workspaceAbs}`)
34
+ }
35
+ }
36
+
37
+ function resolveBinPath(bin: string | Record<string, string> | undefined): string {
38
+ if (typeof bin === 'string') {
39
+ return bin
40
+ }
41
+
42
+ if (typeof bin?.eslint === 'string') {
43
+ return bin.eslint
44
+ }
45
+
46
+ return 'bin/eslint.js'
47
+ }
48
+
49
+ function findPackageRoot(entryAbs: string): string {
50
+ let current = path.dirname(entryAbs)
51
+ while (true) {
52
+ const packageJsonPath = path.join(current, 'package.json')
53
+ if (existsSync(packageJsonPath)) {
54
+ return current
55
+ }
56
+
57
+ const parent = path.dirname(current)
58
+ if (parent === current) {
59
+ throw new Error('Package root not found')
60
+ }
61
+ current = parent
62
+ }
63
+ }
64
+
65
+ export function extractRulesFromPrintConfig(workspaceAbs: string, fileAbs: string): ExtractedWorkspaceRules {
66
+ const eslintBin = resolveEslintBinForWorkspace(workspaceAbs)
67
+ const proc = spawnSync(process.execPath, [eslintBin, '--print-config', fileAbs], {
68
+ cwd: workspaceAbs,
69
+ encoding: 'utf8'
70
+ })
71
+
72
+ if (proc.status !== 0) {
73
+ throw new Error(`Failed to run eslint --print-config for ${fileAbs}`)
74
+ }
75
+
76
+ const stdout = proc.stdout.trim()
77
+ if (stdout.length === 0 || stdout === 'undefined') {
78
+ throw new Error(`Empty ESLint print-config output for ${fileAbs}`)
79
+ }
80
+
81
+ let parsed: unknown
82
+ try {
83
+ parsed = JSON.parse(stdout)
84
+ } catch {
85
+ throw new Error(`Invalid JSON from eslint --print-config for ${fileAbs}`)
86
+ }
87
+
88
+ const rules = (parsed as { rules?: Record<string, unknown> }).rules ?? {}
89
+ const normalized = new Map<string, NormalizedRuleEntry>()
90
+
91
+ for (const [ruleName, ruleConfig] of Object.entries(rules)) {
92
+ normalized.set(ruleName, normalizeRuleEntry(ruleConfig))
93
+ }
94
+
95
+ return normalized
96
+ }
97
+
98
+ function normalizeRuleEntry(raw: unknown): NormalizedRuleEntry {
99
+ if (Array.isArray(raw)) {
100
+ if (raw.length === 0) {
101
+ throw new Error('Rule configuration array cannot be empty')
102
+ }
103
+
104
+ const severity = normalizeSeverity(raw[0])
105
+ const rest = raw.slice(1).map((item) => canonicalizeJson(item))
106
+
107
+ if (rest.length === 0) {
108
+ return [severity]
109
+ }
110
+
111
+ if (rest.length === 1) {
112
+ return [severity, rest[0]]
113
+ }
114
+
115
+ return [severity, canonicalizeJson(rest)]
116
+ }
117
+
118
+ return [normalizeSeverity(raw)]
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { canonicalizeJson, compareSeverity, normalizePath, normalizeSeverity, sortUnique } from './core.js'
2
+ export type { JsonPrimitive, JsonValue } from './core.js'
3
+
4
+ export { assignGroupsByMatch, discoverWorkspaces } from './workspace.js'
5
+ export type { GroupAssignment, GroupDefinition, WorkspaceDiscovery, WorkspaceInput } from './workspace.js'
6
+
7
+ export { sampleWorkspaceFiles } from './sampling.js'
8
+ export type { SamplingConfig } from './sampling.js'
9
+
10
+ export { extractRulesFromPrintConfig, resolveEslintBinForWorkspace } from './extract.js'
11
+ export type { ExtractedWorkspaceRules, NormalizedRuleEntry } from './extract.js'
12
+
13
+ export { aggregateRules, buildSnapshot, readSnapshotFile, writeSnapshotFile } from './snapshot.js'
14
+ export type { SnapshotFile } from './snapshot.js'
15
+
16
+ export { diffSnapshots, hasDiff } from './diff.js'
17
+ export type { RuleOptionChange, RuleSeverityChange, SnapshotDiff, WorkspaceMembershipChange } from './diff.js'
18
+
19
+ export { DEFAULT_CONFIG, findConfigPath, getConfigScaffold, loadConfig } from './config.js'
20
+ export type { ConfigPreset, SnapshotConfig } from './config.js'
@@ -0,0 +1,136 @@
1
+ import fg from 'fast-glob'
2
+ import picomatch from 'picomatch'
3
+
4
+ import { normalizePath, sortUnique } from './core.js'
5
+
6
+ export type SamplingConfig = {
7
+ maxFilesPerWorkspace: number
8
+ includeGlobs: string[]
9
+ excludeGlobs: string[]
10
+ hintGlobs: string[]
11
+ }
12
+
13
+ export async function sampleWorkspaceFiles(workspaceAbs: string, config: SamplingConfig): Promise<string[]> {
14
+ const all = await fg(config.includeGlobs, {
15
+ cwd: workspaceAbs,
16
+ ignore: config.excludeGlobs,
17
+ onlyFiles: true,
18
+ dot: true,
19
+ unique: true
20
+ })
21
+
22
+ const normalized = sortUnique(all.map((entry) => normalizePath(entry)))
23
+ if (normalized.length === 0) {
24
+ return []
25
+ }
26
+
27
+ if (normalized.length <= config.maxFilesPerWorkspace) {
28
+ return normalized
29
+ }
30
+
31
+ if (config.hintGlobs.length === 0) {
32
+ return selectDistributed(normalized, config.maxFilesPerWorkspace)
33
+ }
34
+
35
+ const hinted = normalized.filter((entry) => config.hintGlobs.some((pattern) => picomatch(pattern, { dot: true })(entry)))
36
+ const notHinted = normalized.filter((entry) => !hinted.includes(entry))
37
+
38
+ return selectDistributed([...hinted, ...notHinted], config.maxFilesPerWorkspace)
39
+ }
40
+
41
+ function selectDistributed(files: string[], count: number): string[] {
42
+ if (files.length <= count) {
43
+ return files
44
+ }
45
+
46
+ const selected: string[] = []
47
+ const selectedSet = new Set<string>()
48
+
49
+ // First pass: attempt token diversity using simple filename tokenization.
50
+ const tokenSeen = new Set<string>()
51
+ for (const file of files) {
52
+ if (selected.length >= count) {
53
+ break
54
+ }
55
+ const token = getPrimaryToken(file)
56
+ if (!token || tokenSeen.has(token)) {
57
+ continue
58
+ }
59
+ tokenSeen.add(token)
60
+ selected.push(file)
61
+ selectedSet.add(file)
62
+ }
63
+
64
+ if (selected.length >= count) {
65
+ return sortUnique(selected).slice(0, count)
66
+ }
67
+
68
+ const remaining = files.filter((file) => !selectedSet.has(file))
69
+ const needed = count - selected.length
70
+ const spaced = pickUniformly(remaining, needed)
71
+ return sortUnique([...selected, ...spaced]).slice(0, count)
72
+ }
73
+
74
+ function pickUniformly(files: string[], count: number): string[] {
75
+ if (count <= 0 || files.length === 0) {
76
+ return []
77
+ }
78
+ if (files.length <= count) {
79
+ return files
80
+ }
81
+ if (count === 1) {
82
+ return [files[0]]
83
+ }
84
+
85
+ const picked: string[] = []
86
+ const usedIndices = new Set<number>()
87
+
88
+ for (let index = 0; index < count; index += 1) {
89
+ const raw = Math.round((index * (files.length - 1)) / (count - 1))
90
+ const safeIndex = nextFreeIndex(raw, usedIndices, files.length)
91
+ usedIndices.add(safeIndex)
92
+ picked.push(files[safeIndex])
93
+ }
94
+
95
+ return picked
96
+ }
97
+
98
+ function nextFreeIndex(candidate: number, used: Set<number>, max: number): number {
99
+ if (!used.has(candidate)) {
100
+ return candidate
101
+ }
102
+
103
+ for (let delta = 1; delta < max; delta += 1) {
104
+ const forward = candidate + delta
105
+ if (forward < max && !used.has(forward)) {
106
+ return forward
107
+ }
108
+ const backward = candidate - delta
109
+ if (backward >= 0 && !used.has(backward)) {
110
+ return backward
111
+ }
112
+ }
113
+
114
+ return candidate
115
+ }
116
+
117
+ function getPrimaryToken(file: string): string | null {
118
+ const parts = file.split('/')
119
+ const basename = parts.slice(-1)[0]
120
+ if (!basename) {
121
+ return null
122
+ }
123
+ const nameOnly = basename.replace(/\.[^.]+$/u, '')
124
+ const expanded = nameOnly
125
+ .replaceAll(/([a-z])([A-Z])/gu, '$1 $2')
126
+ .replaceAll(/[_\-.]+/gu, ' ')
127
+ .toLowerCase()
128
+
129
+ const token = expanded
130
+ .split(/\s+/u)
131
+ .find((entry) => entry.length > 1 && !GENERIC_TOKENS.has(entry))
132
+
133
+ return token ?? null
134
+ }
135
+
136
+ const GENERIC_TOKENS = new Set(['src', 'index', 'main', 'test', 'spec', 'package', 'packages', 'lib', 'dist'])
@@ -0,0 +1,76 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { canonicalizeJson, compareSeverity, sortUnique } from './core.js'
5
+
6
+ import type { NormalizedRuleEntry } from './extract.js'
7
+
8
+ export type SnapshotFile = {
9
+ formatVersion: 1
10
+ groupId: string
11
+ workspaces: string[]
12
+ rules: Record<string, NormalizedRuleEntry>
13
+ }
14
+
15
+ export function aggregateRules(ruleMaps: readonly Map<string, NormalizedRuleEntry>[]): Map<string, NormalizedRuleEntry> {
16
+ const aggregated = new Map<string, NormalizedRuleEntry>()
17
+
18
+ for (const rules of ruleMaps) {
19
+ for (const [ruleName, nextEntry] of rules.entries()) {
20
+ const currentEntry = aggregated.get(ruleName)
21
+ if (!currentEntry) {
22
+ aggregated.set(ruleName, canonicalizeJson(nextEntry))
23
+ continue
24
+ }
25
+
26
+ const severityCmp = compareSeverity(nextEntry[0], currentEntry[0])
27
+ if (severityCmp > 0) {
28
+ aggregated.set(ruleName, canonicalizeJson(nextEntry))
29
+ continue
30
+ }
31
+
32
+ if (severityCmp < 0) {
33
+ continue
34
+ }
35
+
36
+ const currentOptions = currentEntry.length > 1 ? canonicalizeJson(currentEntry[1]) : undefined
37
+ const nextOptions = nextEntry.length > 1 ? canonicalizeJson(nextEntry[1]) : undefined
38
+
39
+ if (JSON.stringify(currentOptions) !== JSON.stringify(nextOptions)) {
40
+ throw new Error(`Conflicting rule options for ${ruleName} at severity ${currentEntry[0]}`)
41
+ }
42
+ }
43
+ }
44
+
45
+ return new Map([...aggregated.entries()].sort(([a], [b]) => a.localeCompare(b)))
46
+ }
47
+
48
+ export function buildSnapshot(groupId: string, workspaces: readonly string[], rules: Map<string, NormalizedRuleEntry>): SnapshotFile {
49
+ const sortedRules = [...rules.entries()].sort(([a], [b]) => a.localeCompare(b))
50
+ const rulesObject: Record<string, NormalizedRuleEntry> = {}
51
+
52
+ for (const [name, config] of sortedRules) {
53
+ rulesObject[name] = canonicalizeJson(config)
54
+ }
55
+
56
+ return {
57
+ formatVersion: 1,
58
+ groupId,
59
+ workspaces: sortUnique(workspaces),
60
+ rules: rulesObject
61
+ }
62
+ }
63
+
64
+ export async function writeSnapshotFile(snapshotDirAbs: string, snapshot: SnapshotFile): Promise<string> {
65
+ await mkdir(snapshotDirAbs, { recursive: true })
66
+ const filePath = path.join(snapshotDirAbs, `${snapshot.groupId}.json`)
67
+ await mkdir(path.dirname(filePath), { recursive: true })
68
+ const payload = JSON.stringify(snapshot, null, 2)
69
+ await writeFile(filePath, `${payload}\n`, 'utf8')
70
+ return filePath
71
+ }
72
+
73
+ export async function readSnapshotFile(fileAbs: string): Promise<SnapshotFile> {
74
+ const raw = await readFile(fileAbs, 'utf8')
75
+ return JSON.parse(raw) as SnapshotFile
76
+ }