@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/workspace.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { getPackages } from '@manypkg/get-packages'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import picomatch from 'picomatch'
|
|
4
|
+
|
|
5
|
+
import { normalizePath, sortUnique } from './core.js'
|
|
6
|
+
|
|
7
|
+
export type WorkspaceDiscovery = {
|
|
8
|
+
rootAbs: string
|
|
9
|
+
workspacesRel: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WorkspaceInput =
|
|
13
|
+
| {
|
|
14
|
+
mode: 'discover'
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
mode: 'manual'
|
|
18
|
+
rootAbs?: string
|
|
19
|
+
workspaces: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type GroupDefinition = {
|
|
23
|
+
name: string
|
|
24
|
+
match: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type GroupAssignment = {
|
|
28
|
+
name: string
|
|
29
|
+
workspaces: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function discoverWorkspaces(options?: {
|
|
33
|
+
cwd?: string
|
|
34
|
+
workspaceInput?: WorkspaceInput
|
|
35
|
+
}): Promise<WorkspaceDiscovery> {
|
|
36
|
+
const cwd = options?.cwd ? path.resolve(options.cwd) : process.cwd()
|
|
37
|
+
const workspaceInput = options?.workspaceInput ?? { mode: 'discover' as const }
|
|
38
|
+
|
|
39
|
+
if (workspaceInput.mode === 'manual') {
|
|
40
|
+
const rootAbs = path.resolve(workspaceInput.rootAbs ?? cwd)
|
|
41
|
+
return {
|
|
42
|
+
rootAbs,
|
|
43
|
+
workspacesRel: sortUnique(workspaceInput.workspaces)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { rootDir, packages } = await getPackages(cwd)
|
|
48
|
+
const workspacesAbs = packages.map((pkg) => pkg.dir)
|
|
49
|
+
const rootAbs = rootDir ? path.resolve(rootDir) : lowestCommonAncestor(workspacesAbs)
|
|
50
|
+
const workspacesRel = sortUnique(workspacesAbs.map((entry) => normalizePath(path.relative(rootAbs, entry))))
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
rootAbs,
|
|
54
|
+
workspacesRel
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function assignGroupsByMatch(workspacesRel: readonly string[], groups: readonly GroupDefinition[]): GroupAssignment[] {
|
|
59
|
+
const sortedWorkspaces = sortUnique([...workspacesRel])
|
|
60
|
+
const assignments = new Map<string, string[]>()
|
|
61
|
+
|
|
62
|
+
for (const group of groups) {
|
|
63
|
+
assignments.set(group.name, [])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const unmatched: string[] = []
|
|
67
|
+
|
|
68
|
+
for (const workspace of sortedWorkspaces) {
|
|
69
|
+
let assigned = false
|
|
70
|
+
|
|
71
|
+
for (const group of groups) {
|
|
72
|
+
if (matchesWorkspace(workspace, group.match)) {
|
|
73
|
+
assignments.get(group.name)?.push(workspace)
|
|
74
|
+
assigned = true
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!assigned) {
|
|
80
|
+
unmatched.push(workspace)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (unmatched.length > 0) {
|
|
85
|
+
throw new Error(`Unmatched workspaces: ${unmatched.join(', ')}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return groups.map((group) => ({
|
|
89
|
+
name: group.name,
|
|
90
|
+
workspaces: assignments.get(group.name) ?? []
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function matchesWorkspace(workspace: string, patterns: readonly string[]): boolean {
|
|
95
|
+
const positives = patterns.filter((pattern) => !pattern.startsWith('!'))
|
|
96
|
+
const negatives = patterns.filter((pattern) => pattern.startsWith('!')).map((pattern) => pattern.slice(1))
|
|
97
|
+
|
|
98
|
+
const isPositiveMatch = positives.some((pattern) => picomatch(pattern, { dot: true })(workspace))
|
|
99
|
+
if (!isPositiveMatch) {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isNegativeMatch = negatives.some((pattern) => picomatch(pattern, { dot: true })(workspace))
|
|
104
|
+
return !isNegativeMatch
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function lowestCommonAncestor(paths: readonly string[]): string {
|
|
108
|
+
if (paths.length === 0) {
|
|
109
|
+
return process.cwd()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const segments = paths.map((entry) => path.resolve(entry).split(path.sep))
|
|
113
|
+
const minLen = Math.min(...segments.map((parts) => parts.length))
|
|
114
|
+
|
|
115
|
+
const common: string[] = []
|
|
116
|
+
for (let index = 0; index < minLen; index += 1) {
|
|
117
|
+
const value = segments[0][index]
|
|
118
|
+
if (segments.every((parts) => parts[index] === value)) {
|
|
119
|
+
common.push(value)
|
|
120
|
+
} else {
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (common.length === 0) {
|
|
126
|
+
return path.parse(path.resolve(paths[0])).root
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return common.join(path.sep)
|
|
130
|
+
}
|
package/test/api.test.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { normalizePath, sortUnique } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
describe('api', () => {
|
|
6
|
+
it('re-exports core utils', () => {
|
|
7
|
+
expect(normalizePath('a\\b/')).toBe('a/b')
|
|
8
|
+
expect(sortUnique(['b', 'a'])).toEqual(['a', 'b'])
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { DEFAULT_CONFIG, loadConfig } from '../src/index.js'
|
|
7
|
+
|
|
8
|
+
let tmp = ''
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
if (tmp) {
|
|
12
|
+
await rm(tmp, { recursive: true, force: true })
|
|
13
|
+
tmp = ''
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('loadConfig', () => {
|
|
18
|
+
it('returns defaults when no config is found', async () => {
|
|
19
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
20
|
+
const config = await loadConfig(tmp)
|
|
21
|
+
expect(config).toEqual(DEFAULT_CONFIG)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('uses deterministic search order and picks the first matching file', async () => {
|
|
25
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
26
|
+
await writeFile(path.join(tmp, '.eslint-config-snapshot.cjs'), 'module.exports = { sampling: { maxFilesPerWorkspace: 2 } }\n')
|
|
27
|
+
await writeFile(path.join(tmp, 'eslint-config-snapshot.config.mjs'), 'export default { sampling: { maxFilesPerWorkspace: 9 } }\n')
|
|
28
|
+
|
|
29
|
+
const config = await loadConfig(tmp)
|
|
30
|
+
expect(config.sampling.maxFilesPerWorkspace).toBe(2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('loads config from package.json field', async () => {
|
|
34
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
35
|
+
await writeFile(
|
|
36
|
+
path.join(tmp, 'package.json'),
|
|
37
|
+
JSON.stringify(
|
|
38
|
+
{
|
|
39
|
+
name: 'fixture',
|
|
40
|
+
private: true,
|
|
41
|
+
'eslint-config-snapshot': {
|
|
42
|
+
workspaceInput: { mode: 'manual', workspaces: ['packages/z', 'packages/a'] },
|
|
43
|
+
grouping: { mode: 'standalone' },
|
|
44
|
+
sampling: { maxFilesPerWorkspace: 5, includeGlobs: ['**/*.tsx'] }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
null,
|
|
48
|
+
2
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const config = await loadConfig(tmp)
|
|
53
|
+
expect(config.workspaceInput).toEqual({ mode: 'manual', workspaces: ['packages/z', 'packages/a'] })
|
|
54
|
+
expect(config.grouping.mode).toBe('standalone')
|
|
55
|
+
expect(config.sampling.maxFilesPerWorkspace).toBe(5)
|
|
56
|
+
expect(config.sampling.includeGlobs).toEqual(['**/*.tsx'])
|
|
57
|
+
expect(config.sampling.excludeGlobs).toEqual(DEFAULT_CONFIG.sampling.excludeGlobs)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('loads config from rc json file', async () => {
|
|
61
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
62
|
+
await writeFile(path.join(tmp, '.eslint-config-snapshotrc.json'), JSON.stringify({ sampling: { maxFilesPerWorkspace: 7 } }))
|
|
63
|
+
|
|
64
|
+
const config = await loadConfig(tmp)
|
|
65
|
+
expect(config.sampling.maxFilesPerWorkspace).toBe(7)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('executes function exports', async () => {
|
|
69
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
70
|
+
await writeFile(
|
|
71
|
+
path.join(tmp, '.eslint-config-snapshot.js'),
|
|
72
|
+
'export default () => ({ grouping: { mode: "standalone" }, sampling: { hintGlobs: ["src/**"] } })\n'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const config = await loadConfig(tmp)
|
|
76
|
+
expect(config.grouping.mode).toBe('standalone')
|
|
77
|
+
expect(config.sampling.hintGlobs).toEqual(['src/**'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('executes async function exports', async () => {
|
|
81
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
82
|
+
await writeFile(
|
|
83
|
+
path.join(tmp, '.eslint-config-snapshot.mjs'),
|
|
84
|
+
'export default async () => ({ sampling: { maxFilesPerWorkspace: 3, hintGlobs: ["src/**"] } })\n'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const config = await loadConfig(tmp)
|
|
88
|
+
expect(config.sampling.maxFilesPerWorkspace).toBe(3)
|
|
89
|
+
expect(config.sampling.hintGlobs).toEqual(['src/**'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('throws deterministic error when config export is invalid', async () => {
|
|
93
|
+
tmp = await mkdtemp(path.join(os.tmpdir(), 'snapshot-config-'))
|
|
94
|
+
await writeFile(path.join(tmp, '.eslint-config-snapshot.cjs'), 'module.exports = 42\n')
|
|
95
|
+
|
|
96
|
+
await expect(loadConfig(tmp)).rejects.toThrow(
|
|
97
|
+
'Invalid config export: expected object, function, or async function returning an object'
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { canonicalizeJson, normalizePath, normalizeSeverity, sortUnique } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
describe('core utils', () => {
|
|
6
|
+
it('normalizes path separators and trailing slash', () => {
|
|
7
|
+
expect(normalizePath('packages\\a\\')).toBe('packages/a')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('sorts unique normalized list', () => {
|
|
11
|
+
expect(sortUnique(['b', 'a/', 'a', 'b\\c'])).toEqual(['a', 'b', 'b/c'])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('canonicalizes object keys recursively', () => {
|
|
15
|
+
const input = { z: 1, a: { d: 1, c: 2 } }
|
|
16
|
+
expect(canonicalizeJson(input)).toEqual({ a: { c: 2, d: 1 }, z: 1 })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes numeric severity', () => {
|
|
20
|
+
expect(normalizeSeverity(0)).toBe('off')
|
|
21
|
+
expect(normalizeSeverity(1)).toBe('warn')
|
|
22
|
+
expect(normalizeSeverity(2)).toBe('error')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { diffSnapshots, hasDiff } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
describe('diffSnapshots', () => {
|
|
6
|
+
it('detects rule and workspace changes', () => {
|
|
7
|
+
const before = {
|
|
8
|
+
formatVersion: 1 as const,
|
|
9
|
+
groupId: 'default',
|
|
10
|
+
workspaces: ['packages/a'],
|
|
11
|
+
rules: {
|
|
12
|
+
a: ['warn'] as ['warn'],
|
|
13
|
+
b: ['error', { allow: false }] as ['error', { allow: boolean }]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const after = {
|
|
18
|
+
formatVersion: 1 as const,
|
|
19
|
+
groupId: 'default',
|
|
20
|
+
workspaces: ['packages/b'],
|
|
21
|
+
rules: {
|
|
22
|
+
a: ['error'] as ['error'],
|
|
23
|
+
c: ['warn'] as ['warn']
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const diff = diffSnapshots(before, after)
|
|
28
|
+
expect(diff.introducedRules).toEqual(['c'])
|
|
29
|
+
expect(diff.removedRules).toEqual(['b'])
|
|
30
|
+
expect(diff.severityChanges).toEqual([{ rule: 'a', before: 'warn', after: 'error' }])
|
|
31
|
+
expect(diff.workspaceMembershipChanges).toEqual({ added: ['packages/b'], removed: ['packages/a'] })
|
|
32
|
+
expect(hasDiff(diff)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('ignores option changes when severity is off and keeps meaningful option diffs', () => {
|
|
36
|
+
const before = {
|
|
37
|
+
formatVersion: 1 as const,
|
|
38
|
+
groupId: 'default',
|
|
39
|
+
workspaces: ['packages/a'],
|
|
40
|
+
rules: {
|
|
41
|
+
offRule: ['off', { a: 1 }] as ['off', { a: number }],
|
|
42
|
+
configured: ['error', { allow: false }] as ['error', { allow: boolean }]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const after = {
|
|
47
|
+
formatVersion: 1 as const,
|
|
48
|
+
groupId: 'default',
|
|
49
|
+
workspaces: ['packages/a'],
|
|
50
|
+
rules: {
|
|
51
|
+
offRule: ['off'] as ['off'],
|
|
52
|
+
configured: ['error', { allow: true }] as ['error', { allow: boolean }]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const diff = diffSnapshots(before, after)
|
|
57
|
+
expect(diff.removedRules).toContain('offRule')
|
|
58
|
+
expect(diff.optionChanges).toEqual([{ rule: 'configured', before: { allow: false }, after: { allow: true } }])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { extractRulesFromPrintConfig, resolveEslintBinForWorkspace } from '../src/index.js'
|
|
7
|
+
|
|
8
|
+
const workspace = path.join(os.tmpdir(), `snapshot-extract-${Date.now()}`)
|
|
9
|
+
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await rm(workspace, { recursive: true, force: true })
|
|
12
|
+
await rm(`${workspace}-exports`, { recursive: true, force: true })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('extract', () => {
|
|
16
|
+
it('resolves eslint workspace-locally and normalizes rules', async () => {
|
|
17
|
+
await mkdir(path.join(workspace, 'node_modules/eslint/bin'), { recursive: true })
|
|
18
|
+
await mkdir(path.join(workspace, 'src'), { recursive: true })
|
|
19
|
+
|
|
20
|
+
await writeFile(
|
|
21
|
+
path.join(workspace, 'node_modules/eslint/package.json'),
|
|
22
|
+
JSON.stringify({ name: 'eslint', version: '0.0.0' }, null, 2)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
await writeFile(
|
|
26
|
+
path.join(workspace, 'node_modules/eslint/bin/eslint.js'),
|
|
27
|
+
"console.log(JSON.stringify({ rules: { 'no-console': 1, eqeqeq: [2, 'always'] } }))"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const fileAbs = path.join(workspace, 'src/index.ts')
|
|
31
|
+
await writeFile(fileAbs, 'export {}\n')
|
|
32
|
+
|
|
33
|
+
const resolved = resolveEslintBinForWorkspace(workspace)
|
|
34
|
+
expect(resolved.replaceAll('\\', '/').includes('eslint/bin/eslint')).toBe(true)
|
|
35
|
+
|
|
36
|
+
const rules = extractRulesFromPrintConfig(workspace, fileAbs)
|
|
37
|
+
expect(Object.fromEntries(rules.entries())).toEqual({
|
|
38
|
+
'no-console': ['warn'],
|
|
39
|
+
eqeqeq: ['error', 'always']
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('falls back to eslint package entry when bin subpath is not directly resolvable', async () => {
|
|
44
|
+
const exportedWorkspace = `${workspace}-exports`
|
|
45
|
+
await mkdir(path.join(exportedWorkspace, 'node_modules/eslint/bin'), { recursive: true })
|
|
46
|
+
await mkdir(path.join(exportedWorkspace, 'src'), { recursive: true })
|
|
47
|
+
|
|
48
|
+
await writeFile(
|
|
49
|
+
path.join(exportedWorkspace, 'node_modules/eslint/package.json'),
|
|
50
|
+
JSON.stringify(
|
|
51
|
+
{
|
|
52
|
+
name: 'eslint',
|
|
53
|
+
version: '0.0.0',
|
|
54
|
+
type: 'commonjs',
|
|
55
|
+
main: './index.js',
|
|
56
|
+
exports: {
|
|
57
|
+
'.': './index.js'
|
|
58
|
+
},
|
|
59
|
+
bin: {
|
|
60
|
+
eslint: './bin/eslint.js'
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
null,
|
|
64
|
+
2
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
await writeFile(path.join(exportedWorkspace, 'node_modules/eslint/index.js'), 'module.exports = {}')
|
|
69
|
+
await writeFile(
|
|
70
|
+
path.join(exportedWorkspace, 'node_modules/eslint/bin/eslint.js'),
|
|
71
|
+
"console.log(JSON.stringify({ rules: { 'no-alert': 2 } }))"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const fileAbs = path.join(exportedWorkspace, 'src/index.ts')
|
|
75
|
+
await writeFile(fileAbs, 'export {}\n')
|
|
76
|
+
|
|
77
|
+
const resolved = resolveEslintBinForWorkspace(exportedWorkspace)
|
|
78
|
+
expect(resolved.replaceAll('\\', '/').endsWith('/node_modules/eslint/bin/eslint.js')).toBe(true)
|
|
79
|
+
|
|
80
|
+
const rules = extractRulesFromPrintConfig(exportedWorkspace, fileAbs)
|
|
81
|
+
expect(Object.fromEntries(rules.entries())).toEqual({
|
|
82
|
+
'no-alert': ['error']
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('throws when eslint cannot be resolved from workspace', () => {
|
|
87
|
+
const missingWorkspace = `${workspace}-missing`
|
|
88
|
+
expect(() => resolveEslintBinForWorkspace(missingWorkspace)).toThrow(
|
|
89
|
+
`Unable to resolve eslint from workspace: ${missingWorkspace}`
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('throws when eslint print-config returns invalid JSON', async () => {
|
|
94
|
+
const invalidWorkspace = `${workspace}-invalid-json`
|
|
95
|
+
await mkdir(path.join(invalidWorkspace, 'node_modules/eslint/bin'), { recursive: true })
|
|
96
|
+
await mkdir(path.join(invalidWorkspace, 'src'), { recursive: true })
|
|
97
|
+
await writeFile(path.join(invalidWorkspace, 'node_modules/eslint/package.json'), JSON.stringify({ name: 'eslint', version: '0.0.0' }, null, 2))
|
|
98
|
+
await writeFile(path.join(invalidWorkspace, 'node_modules/eslint/bin/eslint.js'), "console.log('not json')\n")
|
|
99
|
+
|
|
100
|
+
const fileAbs = path.join(invalidWorkspace, 'src/index.ts')
|
|
101
|
+
await writeFile(fileAbs, 'export {}\n')
|
|
102
|
+
|
|
103
|
+
expect(() => extractRulesFromPrintConfig(invalidWorkspace, fileAbs)).toThrow(
|
|
104
|
+
`Invalid JSON from eslint --print-config for ${fileAbs}`
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('throws when eslint print-config returns undefined output', async () => {
|
|
109
|
+
const undefinedWorkspace = `${workspace}-undefined`
|
|
110
|
+
await mkdir(path.join(undefinedWorkspace, 'node_modules/eslint/bin'), { recursive: true })
|
|
111
|
+
await mkdir(path.join(undefinedWorkspace, 'src'), { recursive: true })
|
|
112
|
+
await writeFile(path.join(undefinedWorkspace, 'node_modules/eslint/package.json'), JSON.stringify({ name: 'eslint', version: '0.0.0' }, null, 2))
|
|
113
|
+
await writeFile(path.join(undefinedWorkspace, 'node_modules/eslint/bin/eslint.js'), "console.log('undefined')\n")
|
|
114
|
+
|
|
115
|
+
const fileAbs = path.join(undefinedWorkspace, 'src/index.ts')
|
|
116
|
+
await writeFile(fileAbs, 'export {}\n')
|
|
117
|
+
|
|
118
|
+
expect(() => extractRulesFromPrintConfig(undefinedWorkspace, fileAbs)).toThrow(
|
|
119
|
+
`Empty ESLint print-config output for ${fileAbs}`
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('throws when eslint print-config exits non-zero', async () => {
|
|
124
|
+
const failingWorkspace = `${workspace}-failing`
|
|
125
|
+
await mkdir(path.join(failingWorkspace, 'node_modules/eslint/bin'), { recursive: true })
|
|
126
|
+
await mkdir(path.join(failingWorkspace, 'src'), { recursive: true })
|
|
127
|
+
await writeFile(path.join(failingWorkspace, 'node_modules/eslint/package.json'), JSON.stringify({ name: 'eslint', version: '0.0.0' }, null, 2))
|
|
128
|
+
await writeFile(
|
|
129
|
+
path.join(failingWorkspace, 'node_modules/eslint/bin/eslint.js'),
|
|
130
|
+
"process.stderr.write('failure'); process.exit(2)\n"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const fileAbs = path.join(failingWorkspace, 'src/index.ts')
|
|
134
|
+
await writeFile(fileAbs, 'export {}\n')
|
|
135
|
+
|
|
136
|
+
expect(() => extractRulesFromPrintConfig(failingWorkspace, fileAbs)).toThrow(
|
|
137
|
+
`Failed to run eslint --print-config for ${fileAbs}`
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { sampleWorkspaceFiles } from '../src/index.js'
|
|
7
|
+
|
|
8
|
+
const tmp = path.join(os.tmpdir(), `snapshot-sampling-${Date.now()}`)
|
|
9
|
+
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await import('node:fs/promises').then((fs) => fs.rm(tmp, { recursive: true, force: true }))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('sampleWorkspaceFiles', () => {
|
|
15
|
+
it('returns deterministic sorted sample', async () => {
|
|
16
|
+
await mkdir(path.join(tmp, 'src'), { recursive: true })
|
|
17
|
+
await writeFile(path.join(tmp, 'src', 'b.ts'), '')
|
|
18
|
+
await writeFile(path.join(tmp, 'src', 'a.ts'), '')
|
|
19
|
+
|
|
20
|
+
const result = await sampleWorkspaceFiles(tmp, {
|
|
21
|
+
maxFilesPerWorkspace: 8,
|
|
22
|
+
includeGlobs: ['**/*.ts'],
|
|
23
|
+
excludeGlobs: [],
|
|
24
|
+
hintGlobs: []
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(result).toEqual(['src/a.ts', 'src/b.ts'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('selects uniformly distributed files when candidates exceed max', async () => {
|
|
31
|
+
await mkdir(path.join(tmp, 'many'), { recursive: true })
|
|
32
|
+
for (let index = 0; index < 20; index += 1) {
|
|
33
|
+
const name = `file-${String(index).padStart(2, '0')}.ts`
|
|
34
|
+
await writeFile(path.join(tmp, 'many', name), '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = await sampleWorkspaceFiles(tmp, {
|
|
38
|
+
maxFilesPerWorkspace: 8,
|
|
39
|
+
includeGlobs: ['many/**/*.ts'],
|
|
40
|
+
excludeGlobs: [],
|
|
41
|
+
hintGlobs: []
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual([
|
|
45
|
+
'many/file-00.ts',
|
|
46
|
+
'many/file-01.ts',
|
|
47
|
+
'many/file-04.ts',
|
|
48
|
+
'many/file-07.ts',
|
|
49
|
+
'many/file-10.ts',
|
|
50
|
+
'many/file-13.ts',
|
|
51
|
+
'many/file-16.ts',
|
|
52
|
+
'many/file-19.ts'
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('prefers token-diverse hinted files before fallback spacing', async () => {
|
|
57
|
+
await mkdir(path.join(tmp, 'tokens'), { recursive: true })
|
|
58
|
+
const files = [
|
|
59
|
+
'tokens/auth.service.ts',
|
|
60
|
+
'tokens/billing.service.ts',
|
|
61
|
+
'tokens/catalog.service.ts',
|
|
62
|
+
'tokens/auth.controller.ts',
|
|
63
|
+
'tokens/billing.controller.ts',
|
|
64
|
+
'tokens/catalog.controller.ts',
|
|
65
|
+
'tokens/shared.util.ts',
|
|
66
|
+
'tokens/shared.helper.ts',
|
|
67
|
+
'tokens/shared.format.ts',
|
|
68
|
+
'tokens/shared.view.ts'
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
await writeFile(path.join(tmp, file), '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = await sampleWorkspaceFiles(tmp, {
|
|
76
|
+
maxFilesPerWorkspace: 6,
|
|
77
|
+
includeGlobs: ['tokens/**/*.ts'],
|
|
78
|
+
excludeGlobs: [],
|
|
79
|
+
hintGlobs: ['tokens/**/*.service.ts']
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual([
|
|
83
|
+
'tokens/auth.controller.ts',
|
|
84
|
+
'tokens/auth.service.ts',
|
|
85
|
+
'tokens/billing.service.ts',
|
|
86
|
+
'tokens/catalog.service.ts',
|
|
87
|
+
'tokens/shared.format.ts',
|
|
88
|
+
'tokens/shared.view.ts'
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { aggregateRules, buildSnapshot, writeSnapshotFile } from '../src/index.js'
|
|
7
|
+
|
|
8
|
+
let tmpDir = ''
|
|
9
|
+
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
if (tmpDir) {
|
|
12
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('snapshot', () => {
|
|
17
|
+
it('writes deterministic json', async () => {
|
|
18
|
+
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'snapshot-snapshot-'))
|
|
19
|
+
|
|
20
|
+
const snapshot = buildSnapshot('default', ['packages/b', 'packages/a'], new Map([
|
|
21
|
+
['z-rule', ['warn']],
|
|
22
|
+
['a-rule', ['error', { b: 1, a: 2 }]]
|
|
23
|
+
]))
|
|
24
|
+
|
|
25
|
+
const output = await writeSnapshotFile(tmpDir, snapshot)
|
|
26
|
+
const content = await readFile(output, 'utf8')
|
|
27
|
+
|
|
28
|
+
expect(content).toContain('"formatVersion": 1')
|
|
29
|
+
expect(content.endsWith('\n')).toBe(true)
|
|
30
|
+
expect(content.indexOf('"a-rule"')).toBeLessThan(content.indexOf('"z-rule"'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('aggregates by highest severity', () => {
|
|
34
|
+
const result = aggregateRules([
|
|
35
|
+
new Map([['no-console', ['warn'] as const]]),
|
|
36
|
+
new Map([['no-console', ['error'] as const], ['eqeqeq', ['error', 'always'] as const]])
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
expect(Object.fromEntries(result.entries())).toEqual({
|
|
40
|
+
eqeqeq: ['error', 'always'],
|
|
41
|
+
'no-console': ['error']
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('uses options from highest severity when severities differ', () => {
|
|
46
|
+
const result = aggregateRules([
|
|
47
|
+
new Map([['no-unused-vars', ['warn', { args: 'none' }] as const]]),
|
|
48
|
+
new Map([['no-unused-vars', ['error', { argsIgnorePattern: '^_' }] as const]])
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
expect(Object.fromEntries(result.entries())).toEqual({
|
|
52
|
+
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
|
53
|
+
})
|
|
54
|
+
})
|
|
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')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { assignGroupsByMatch } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
describe('assignGroupsByMatch', () => {
|
|
6
|
+
it('assigns by first matching group and supports negatives', () => {
|
|
7
|
+
const result = assignGroupsByMatch(['ops/a', 'packages/new', 'packages/legacy/x'], [
|
|
8
|
+
{ name: 'ops', match: ['ops/**'] },
|
|
9
|
+
{ name: 'modern', match: ['packages/**', '!packages/legacy/**'] },
|
|
10
|
+
{ name: 'default', match: ['**/*'] }
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
expect(result).toEqual([
|
|
14
|
+
{ name: 'ops', workspaces: ['ops/a'] },
|
|
15
|
+
{ name: 'modern', workspaces: ['packages/new'] },
|
|
16
|
+
{ name: 'default', workspaces: ['packages/legacy/x'] }
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('throws deterministic unmatched error', () => {
|
|
21
|
+
expect(() => assignGroupsByMatch(['packages/a'], [{ name: 'ops', match: ['ops/**'] }])).toThrow(
|
|
22
|
+
'Unmatched workspaces: packages/a'
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
})
|