@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/dist/index.cjs +610 -0
- package/dist/index.js +554 -0
- package/package.json +24 -0
- package/project.json +35 -0
- package/src/config.ts +139 -0
- package/src/core.ts +55 -0
- package/src/diff.ts +103 -0
- package/src/extract.ts +119 -0
- package/src/index.ts +20 -0
- package/src/sampling.ts +136 -0
- package/src/snapshot.ts +76 -0
- package/src/workspace.ts +130 -0
- package/test/api.test.ts +10 -0
- package/test/config.test.ts +100 -0
- package/test/core.test.ts +24 -0
- package/test/diff.test.ts +60 -0
- package/test/extract.test.ts +140 -0
- package/test/sampling.test.ts +91 -0
- package/test/snapshot.test.ts +64 -0
- package/test/workspace.test.ts +25 -0
- package/tsconfig.json +12 -0
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'
|
package/src/sampling.ts
ADDED
|
@@ -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'])
|
package/src/snapshot.ts
ADDED
|
@@ -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
|
+
}
|