@verekia/warden 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verekia/warden",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "description": "Linter that checks repositories share consistent configs and tool versions.",
5
5
  "bin": {
6
6
  "warden": "./src/index.ts"
@@ -41,8 +41,7 @@
41
41
  "enabled": true,
42
42
  "files": [
43
43
  ".oxfmtrc.json",
44
- ".oxlintrc.json",
45
- ".github/workflows/ci.yml"
44
+ ".oxlintrc.json"
46
45
  ]
47
46
  },
48
47
  "matchingDependencyVersions": {
@@ -102,6 +101,11 @@
102
101
  "wardenScript": {
103
102
  "enabled": true,
104
103
  "package": "@verekia/warden"
104
+ },
105
+ "ciWorkflow": {
106
+ "enabled": true,
107
+ "file": ".github/workflows/ci.yml",
108
+ "runs": "bun run all"
105
109
  }
106
110
  }
107
111
  }
@@ -0,0 +1,58 @@
1
+ import type { CheckResult, Project, WardenConfig } from '../types.ts'
2
+
3
+ type Step = { run?: unknown }
4
+ type Job = { steps?: unknown }
5
+ type Workflow = { jobs?: Record<string, unknown> }
6
+
7
+ const DEFAULT_FILE = '.github/workflows/ci.yml'
8
+ const DEFAULT_RUNS = 'bun run all'
9
+
10
+ const collectRunCommands = (workflow: Workflow): string[] => {
11
+ const commands: string[] = []
12
+ for (const job of Object.values(workflow.jobs ?? {})) {
13
+ const steps = (job as Job)?.steps
14
+ if (!Array.isArray(steps)) continue
15
+ for (const step of steps as Step[]) {
16
+ if (typeof step?.run === 'string') commands.push(step.run)
17
+ }
18
+ }
19
+ return commands
20
+ }
21
+
22
+ export const ciWorkflow = async (
23
+ projects: Project[],
24
+ options: NonNullable<NonNullable<WardenConfig['checks']>['ciWorkflow']>,
25
+ ): Promise<CheckResult> => {
26
+ const messages: string[] = []
27
+ let passed = true
28
+
29
+ const file = options.file ?? DEFAULT_FILE
30
+ const runs = options.runs ?? DEFAULT_RUNS
31
+
32
+ for (const project of projects) {
33
+ const handle = Bun.file(`${project.path}/${file}`)
34
+ if (!(await handle.exists())) {
35
+ passed = false
36
+ messages.push(`${project.name}: missing ${file}`)
37
+ continue
38
+ }
39
+ if (!runs) continue
40
+
41
+ let workflow: Workflow
42
+ try {
43
+ workflow = Bun.YAML.parse(await handle.text()) as Workflow
44
+ } catch (err) {
45
+ passed = false
46
+ messages.push(`${project.name}: ${file} is not valid YAML (${(err as Error).message})`)
47
+ continue
48
+ }
49
+
50
+ const commands = collectRunCommands(workflow)
51
+ if (!commands.some(cmd => cmd.includes(runs))) {
52
+ passed = false
53
+ messages.push(`${project.name}: ${file} has no step running ${JSON.stringify(runs)}`)
54
+ }
55
+ }
56
+
57
+ return { name: 'ciWorkflow', passed, messages }
58
+ }
@@ -2,13 +2,14 @@ import type { CheckResult, Project, WardenConfig } from '../types.ts'
2
2
 
3
3
  export const configFilesPresent = async (
4
4
  projects: Project[],
5
- options: NonNullable<WardenConfig['checks']['configFilesPresent']>,
5
+ options: NonNullable<NonNullable<WardenConfig['checks']>['configFilesPresent']>,
6
6
  ): Promise<CheckResult> => {
7
7
  const messages: string[] = []
8
8
  let passed = true
9
9
 
10
+ const files = options.files ?? []
10
11
  for (const project of projects) {
11
- for (const file of options.files) {
12
+ for (const file of files) {
12
13
  const exists = await Bun.file(`${project.path}/${file}`).exists()
13
14
  if (!exists) {
14
15
  passed = false
@@ -7,18 +7,23 @@ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/
7
7
 
8
8
  export const exactDependencyVersions = (
9
9
  projects: Project[],
10
- options: NonNullable<WardenConfig['checks']['exactDependencyVersions']>,
10
+ options: NonNullable<NonNullable<WardenConfig['checks']>['exactDependencyVersions']>,
11
11
  ): CheckResult => {
12
12
  const messages: string[] = []
13
13
  let passed = true
14
14
 
15
+ const packages = Object.entries(options.packages ?? {})
16
+ if (packages.length === 0) {
17
+ return { name: 'exactDependencyVersions', passed, messages }
18
+ }
19
+
15
20
  for (const project of projects) {
16
21
  if (!project.packageJson) {
17
22
  passed = false
18
23
  messages.push(`${project.name}: no package.json`)
19
24
  continue
20
25
  }
21
- for (const [name, expected] of Object.entries(options.packages)) {
26
+ for (const [name, expected] of packages) {
22
27
  const declared = findVersion(project.packageJson, name)
23
28
  if (declared === undefined) {
24
29
  passed = false
@@ -5,12 +5,12 @@ const findVersion = (pkg: PackageJson, name: string): string | undefined =>
5
5
 
6
6
  export const matchingDependencyVersions = (
7
7
  projects: Project[],
8
- options: NonNullable<WardenConfig['checks']['matchingDependencyVersions']>,
8
+ options: NonNullable<NonNullable<WardenConfig['checks']>['matchingDependencyVersions']>,
9
9
  ): CheckResult => {
10
10
  const messages: string[] = []
11
11
  let passed = true
12
12
 
13
- for (const packageName of options.packages) {
13
+ for (const packageName of options.packages ?? []) {
14
14
  const versionToProjects = new Map<string, string[]>()
15
15
  const missing: string[] = []
16
16
 
@@ -66,7 +66,7 @@ const loadConfigObject = async (absPath: string): Promise<Record<string, unknown
66
66
 
67
67
  export const nextConfig = async (
68
68
  projects: Project[],
69
- options: NonNullable<WardenConfig['checks']['nextConfig']>,
69
+ options: NonNullable<NonNullable<WardenConfig['checks']>['nextConfig']>,
70
70
  ): Promise<CheckResult> => {
71
71
  const messages: string[] = []
72
72
  let passed = true
@@ -106,7 +106,7 @@ export const nextConfig = async (
106
106
  }
107
107
 
108
108
  if (cfg) {
109
- for (const [key, expected] of Object.entries(options.options)) {
109
+ for (const [key, expected] of Object.entries(options.options ?? {})) {
110
110
  if (!equal(cfg[key], expected)) {
111
111
  passed = false
112
112
  const actual = key in cfg ? JSON.stringify(cfg[key]) : 'missing'
@@ -118,7 +118,7 @@ export const nextConfig = async (
118
118
  }
119
119
  }
120
120
 
121
- for (const [name, expected] of Object.entries(options.devDependencies)) {
121
+ for (const [name, expected] of Object.entries(options.devDependencies ?? {})) {
122
122
  const declared = pkg.devDependencies?.[name]
123
123
  if (declared !== expected) {
124
124
  const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
@@ -43,7 +43,7 @@ const collectPackageJsons = async (project: Project): Promise<Array<{ relPath: s
43
43
 
44
44
  export const portlessNextDev = async (
45
45
  projects: Project[],
46
- options: NonNullable<WardenConfig['checks']['portlessNextDev']>,
46
+ options: NonNullable<NonNullable<WardenConfig['checks']>['portlessNextDev']>,
47
47
  ): Promise<CheckResult> => {
48
48
  const messages: string[] = []
49
49
  let passed = true
@@ -66,7 +66,7 @@ export const portlessNextDev = async (
66
66
  }
67
67
  }
68
68
 
69
- if (usesNextDev) {
69
+ if (usesNextDev && options.version !== undefined) {
70
70
  const declared = pkg.devDependencies?.portless
71
71
  if (declared !== options.version) {
72
72
  const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
@@ -2,11 +2,16 @@ import type { CheckResult, Project, WardenConfig } from '../types.ts'
2
2
 
3
3
  export const requiredScripts = (
4
4
  projects: Project[],
5
- options: NonNullable<WardenConfig['checks']['requiredScripts']>,
5
+ options: NonNullable<NonNullable<WardenConfig['checks']>['requiredScripts']>,
6
6
  ): CheckResult => {
7
7
  const messages: string[] = []
8
8
  let passed = true
9
9
 
10
+ const required = Object.entries(options.scripts ?? {})
11
+ if (required.length === 0) {
12
+ return { name: 'requiredScripts', passed, messages }
13
+ }
14
+
10
15
  for (const project of projects) {
11
16
  const scripts = project.packageJson?.scripts
12
17
  if (!scripts) {
@@ -14,7 +19,7 @@ export const requiredScripts = (
14
19
  messages.push(`${project.name}: no "scripts" in package.json`)
15
20
  continue
16
21
  }
17
- for (const [name, expected] of Object.entries(options.scripts)) {
22
+ for (const [name, expected] of required) {
18
23
  const actual = scripts[name]
19
24
  if (actual === undefined) {
20
25
  passed = false
@@ -11,7 +11,7 @@ const dependsOnTailwind = (pkg: PackageJson): boolean =>
11
11
 
12
12
  export const tailwindOxfmtConfig = async (
13
13
  projects: Project[],
14
- _options: NonNullable<WardenConfig['checks']['tailwindOxfmtConfig']>,
14
+ _options: NonNullable<NonNullable<WardenConfig['checks']>['tailwindOxfmtConfig']>,
15
15
  ): Promise<CheckResult> => {
16
16
  const messages: string[] = []
17
17
  let passed = true
@@ -11,32 +11,38 @@ const projectHasTests = async (projectPath: string, pattern: string): Promise<bo
11
11
  return false
12
12
  }
13
13
 
14
+ const DEFAULT_TEST_FILE_PATTERN = '**/*.test.{ts,tsx,js,jsx}'
15
+ const DEFAULT_TEST_SCRIPT = 'bun test'
16
+
14
17
  export const testScriptConsistency = async (
15
18
  projects: Project[],
16
- options: NonNullable<WardenConfig['checks']['testScriptConsistency']>,
19
+ options: NonNullable<NonNullable<WardenConfig['checks']>['testScriptConsistency']>,
17
20
  ): Promise<CheckResult> => {
18
21
  const messages: string[] = []
19
22
  let passed = true
20
23
 
24
+ const testFilePattern = options.testFilePattern ?? DEFAULT_TEST_FILE_PATTERN
25
+ const testScript = options.testScript ?? DEFAULT_TEST_SCRIPT
26
+
21
27
  for (const project of projects) {
22
28
  const scripts = project.packageJson?.scripts ?? {}
23
- const hasTests = await projectHasTests(project.path, options.testFilePattern)
29
+ const hasTests = await projectHasTests(project.path, testFilePattern)
24
30
 
25
31
  if (hasTests) {
26
- if (scripts.test !== options.testScript) {
32
+ if (scripts.test !== testScript) {
27
33
  passed = false
28
34
  messages.push(
29
- `${project.name}: has test files but "test" script is ${JSON.stringify(scripts.test)}, expected ${JSON.stringify(options.testScript)}`,
35
+ `${project.name}: has test files but "test" script is ${JSON.stringify(scripts.test)}, expected ${JSON.stringify(testScript)}`,
30
36
  )
31
37
  }
32
- if (scripts.all !== options.allWithTests) {
38
+ if (options.allWithTests !== undefined && scripts.all !== options.allWithTests) {
33
39
  passed = false
34
40
  messages.push(
35
41
  `${project.name}: "all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(options.allWithTests)} (project has tests)`,
36
42
  )
37
43
  }
38
44
  } else {
39
- if (scripts.all !== options.allWithoutTests) {
45
+ if (options.allWithoutTests !== undefined && scripts.all !== options.allWithoutTests) {
40
46
  passed = false
41
47
  messages.push(
42
48
  `${project.name}: "all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(options.allWithoutTests)} (no tests detected)`,
@@ -17,7 +17,7 @@ const readTsconfigReferences = async (projectPath: string): Promise<boolean> =>
17
17
 
18
18
  export const typecheckScript = async (
19
19
  projects: Project[],
20
- options: NonNullable<WardenConfig['checks']['typecheckScript']>,
20
+ options: NonNullable<NonNullable<WardenConfig['checks']>['typecheckScript']>,
21
21
  ): Promise<CheckResult> => {
22
22
  const messages: string[] = []
23
23
  let passed = true
@@ -3,28 +3,31 @@ import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.t
3
3
  const dependsOn = (pkg: PackageJson, name: string): boolean =>
4
4
  name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {}) || name in (pkg.peerDependencies ?? {})
5
5
 
6
+ const DEFAULT_PACKAGE = '@verekia/warden'
7
+
6
8
  export const wardenScript = (
7
9
  projects: Project[],
8
- options: NonNullable<WardenConfig['checks']['wardenScript']>,
10
+ options: NonNullable<NonNullable<WardenConfig['checks']>['wardenScript']>,
9
11
  ): CheckResult => {
10
12
  const messages: string[] = []
11
13
  let passed = true
14
+ const packageName = options.package ?? DEFAULT_PACKAGE
12
15
 
13
16
  for (const project of projects) {
14
17
  const pkg = project.packageJson
15
- if (!pkg || !dependsOn(pkg, options.package)) continue
18
+ if (!pkg || !dependsOn(pkg, packageName)) continue
16
19
 
17
20
  const scripts = pkg.scripts ?? {}
18
21
  if (scripts.warden !== 'warden') {
19
22
  passed = false
20
23
  messages.push(
21
- `${project.name}: depends on ${options.package} but "warden" script is ${JSON.stringify(scripts.warden)}, expected "warden"`,
24
+ `${project.name}: depends on ${packageName} but "warden" script is ${JSON.stringify(scripts.warden)}, expected "warden"`,
22
25
  )
23
26
  }
24
27
  if (!scripts.all || !scripts.all.includes('bun run warden')) {
25
28
  passed = false
26
29
  messages.push(
27
- `${project.name}: depends on ${options.package} but "all" script ${JSON.stringify(scripts.all)} is missing "bun run warden"`,
30
+ `${project.name}: depends on ${packageName} but "all" script ${JSON.stringify(scripts.all)} is missing "bun run warden"`,
28
31
  )
29
32
  }
30
33
  }
package/src/config.ts CHANGED
@@ -9,10 +9,7 @@ export const loadConfig = async (baseDir: string): Promise<WardenConfig> => {
9
9
  throw new Error(`No package.json found at ${packageJsonPath}`)
10
10
  }
11
11
  const pkg = (await packageJsonFile.json()) as PackageJson & { warden?: WardenConfig }
12
- if (!pkg.warden) {
13
- throw new Error(`No "warden" key in ${packageJsonPath}`)
14
- }
15
- return pkg.warden
12
+ return pkg.warden ?? {}
16
13
  }
17
14
 
18
15
  export const resolveProjects = async (config: WardenConfig, baseDir: string): Promise<Project[]> => {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { existsSync } from 'node:fs'
3
3
 
4
+ import { ciWorkflow } from './checks/ci-workflow.ts'
4
5
  import { configFilesPresent } from './checks/config-files-present.ts'
5
6
  import { exactDependencyVersions } from './checks/exact-dependency-versions.ts'
6
7
  import { matchingDependencyVersions } from './checks/matching-dependency-versions.ts'
@@ -46,36 +47,41 @@ const scopeNote = filterNames.length > 0 ? ` (filtered from ${allProjects.length
46
47
  console.log(`warden — checking ${projects.length} project(s)${scopeNote}: ${projects.map(p => p.name).join(', ')}\n`)
47
48
 
48
49
  const results: CheckResult[] = []
50
+ const checks = config.checks ?? {}
51
+ const enabled = (c: { enabled?: boolean } | undefined): boolean => c?.enabled !== false
49
52
 
50
- if (config.checks.configFilesPresent?.enabled) {
51
- results.push(await configFilesPresent(projects, config.checks.configFilesPresent))
53
+ if (enabled(checks.configFilesPresent)) {
54
+ results.push(await configFilesPresent(projects, checks.configFilesPresent ?? {}))
52
55
  }
53
- if (config.checks.matchingDependencyVersions?.enabled) {
54
- results.push(matchingDependencyVersions(projects, config.checks.matchingDependencyVersions))
56
+ if (enabled(checks.matchingDependencyVersions)) {
57
+ results.push(matchingDependencyVersions(projects, checks.matchingDependencyVersions ?? {}))
55
58
  }
56
- if (config.checks.exactDependencyVersions?.enabled) {
57
- results.push(exactDependencyVersions(projects, config.checks.exactDependencyVersions))
59
+ if (enabled(checks.exactDependencyVersions)) {
60
+ results.push(exactDependencyVersions(projects, checks.exactDependencyVersions ?? {}))
58
61
  }
59
- if (config.checks.requiredScripts?.enabled) {
60
- results.push(requiredScripts(projects, config.checks.requiredScripts))
62
+ if (enabled(checks.requiredScripts)) {
63
+ results.push(requiredScripts(projects, checks.requiredScripts ?? {}))
61
64
  }
62
- if (config.checks.typecheckScript?.enabled) {
63
- results.push(await typecheckScript(projects, config.checks.typecheckScript))
65
+ if (enabled(checks.typecheckScript)) {
66
+ results.push(await typecheckScript(projects, checks.typecheckScript ?? {}))
64
67
  }
65
- if (config.checks.testScriptConsistency?.enabled) {
66
- results.push(await testScriptConsistency(projects, config.checks.testScriptConsistency))
68
+ if (enabled(checks.testScriptConsistency)) {
69
+ results.push(await testScriptConsistency(projects, checks.testScriptConsistency ?? {}))
67
70
  }
68
- if (config.checks.tailwindOxfmtConfig?.enabled) {
69
- results.push(await tailwindOxfmtConfig(projects, config.checks.tailwindOxfmtConfig))
71
+ if (enabled(checks.tailwindOxfmtConfig)) {
72
+ results.push(await tailwindOxfmtConfig(projects, checks.tailwindOxfmtConfig ?? {}))
70
73
  }
71
- if (config.checks.portlessNextDev?.enabled) {
72
- results.push(await portlessNextDev(projects, config.checks.portlessNextDev))
74
+ if (enabled(checks.portlessNextDev)) {
75
+ results.push(await portlessNextDev(projects, checks.portlessNextDev ?? {}))
73
76
  }
74
- if (config.checks.nextConfig?.enabled) {
75
- results.push(await nextConfig(projects, config.checks.nextConfig))
77
+ if (enabled(checks.nextConfig)) {
78
+ results.push(await nextConfig(projects, checks.nextConfig ?? {}))
76
79
  }
77
- if (config.checks.wardenScript?.enabled) {
78
- results.push(wardenScript(projects, config.checks.wardenScript))
80
+ if (enabled(checks.wardenScript)) {
81
+ results.push(wardenScript(projects, checks.wardenScript ?? {}))
82
+ }
83
+ if (enabled(checks.ciWorkflow)) {
84
+ results.push(await ciWorkflow(projects, checks.ciWorkflow ?? {}))
79
85
  }
80
86
 
81
87
  for (const result of results) {
package/src/types.ts CHANGED
@@ -1,26 +1,27 @@
1
1
  export type WardenConfig = {
2
2
  projects?: string[]
3
- checks: {
4
- configFilesPresent?: { enabled: boolean; files: string[] }
5
- matchingDependencyVersions?: { enabled: boolean; packages: string[] }
6
- exactDependencyVersions?: { enabled: boolean; packages: Record<string, string> }
7
- requiredScripts?: { enabled: boolean; scripts: Record<string, string> }
8
- typecheckScript?: { enabled: boolean; modes?: Record<string, 'auto' | 'bun-filter-all'> }
3
+ checks?: {
4
+ configFilesPresent?: { enabled?: boolean; files?: string[] }
5
+ matchingDependencyVersions?: { enabled?: boolean; packages?: string[] }
6
+ exactDependencyVersions?: { enabled?: boolean; packages?: Record<string, string> }
7
+ requiredScripts?: { enabled?: boolean; scripts?: Record<string, string> }
8
+ typecheckScript?: { enabled?: boolean; modes?: Record<string, 'auto' | 'bun-filter-all'> }
9
9
  testScriptConsistency?: {
10
- enabled: boolean
11
- testFilePattern: string
12
- testScript: string
13
- allWithoutTests: string
14
- allWithTests: string
10
+ enabled?: boolean
11
+ testFilePattern?: string
12
+ testScript?: string
13
+ allWithoutTests?: string
14
+ allWithTests?: string
15
15
  }
16
- tailwindOxfmtConfig?: { enabled: boolean }
17
- portlessNextDev?: { enabled: boolean; version: string }
16
+ tailwindOxfmtConfig?: { enabled?: boolean }
17
+ portlessNextDev?: { enabled?: boolean; version?: string }
18
18
  nextConfig?: {
19
- enabled: boolean
20
- options: Record<string, unknown>
21
- devDependencies: Record<string, string>
19
+ enabled?: boolean
20
+ options?: Record<string, unknown>
21
+ devDependencies?: Record<string, string>
22
22
  }
23
- wardenScript?: { enabled: boolean; package: string }
23
+ wardenScript?: { enabled?: boolean; package?: string }
24
+ ciWorkflow?: { enabled?: boolean; file?: string; runs?: string }
24
25
  }
25
26
  }
26
27