@verekia/warden 0.0.2 → 0.0.4
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 +14 -68
- package/src/all-siblings.ts +53 -0
- package/src/checks/ci-workflow.ts +6 -10
- package/src/checks/config-files-present.ts +7 -7
- package/src/checks/exact-dependency-versions.ts +17 -16
- package/src/checks/matching-dependency-versions.ts +21 -13
- package/src/checks/next-config.ts +27 -20
- package/src/checks/pinned-dependency-versions.ts +94 -0
- package/src/checks/portless-next-dev.ts +15 -14
- package/src/checks/required-scripts.ts +21 -14
- package/src/checks/tailwind-oxfmt-config.ts +11 -10
- package/src/checks/test-script-consistency.ts +22 -18
- package/src/checks/typecheck-script.ts +12 -12
- package/src/checks/warden-script.ts +11 -12
- package/src/ci-status.ts +98 -0
- package/src/index.ts +32 -7
- package/src/types.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@verekia/warden",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Linter that checks repositories share consistent configs and tool versions.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"warden": "./src/index.ts"
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"type": "module",
|
|
12
12
|
"scripts": {
|
|
13
13
|
"warden": "bun run src/index.ts",
|
|
14
|
+
"all-siblings": "bun run src/all-siblings.ts",
|
|
15
|
+
"ci-status": "bun run src/ci-status.ts",
|
|
14
16
|
"format": "oxfmt .",
|
|
15
17
|
"format:check": "oxfmt --check .",
|
|
16
18
|
"lint": "oxlint .",
|
|
@@ -34,78 +36,22 @@
|
|
|
34
36
|
"../gputex",
|
|
35
37
|
"../gradient-normal-textures",
|
|
36
38
|
"../nanothree",
|
|
37
|
-
"../svg-to-tsl"
|
|
39
|
+
"../svg-to-tsl",
|
|
40
|
+
"../manalab",
|
|
41
|
+
"../tslfx",
|
|
42
|
+
"../polydraw",
|
|
43
|
+
"../r3f-gamedev",
|
|
44
|
+
"../webgamer",
|
|
45
|
+
"../voidcore",
|
|
46
|
+
"../jsblender"
|
|
38
47
|
],
|
|
39
48
|
"checks": {
|
|
40
|
-
"configFilesPresent": {
|
|
41
|
-
"enabled": true,
|
|
42
|
-
"files": [
|
|
43
|
-
".oxfmtrc.json",
|
|
44
|
-
".oxlintrc.json"
|
|
45
|
-
]
|
|
46
|
-
},
|
|
47
|
-
"matchingDependencyVersions": {
|
|
48
|
-
"enabled": true,
|
|
49
|
-
"packages": [
|
|
50
|
-
"oxfmt",
|
|
51
|
-
"oxlint"
|
|
52
|
-
]
|
|
53
|
-
},
|
|
54
|
-
"exactDependencyVersions": {
|
|
55
|
-
"enabled": true,
|
|
56
|
-
"packages": {
|
|
57
|
-
"oxfmt": "0.48.0",
|
|
58
|
-
"oxlint": "1.63.0"
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
"requiredScripts": {
|
|
62
|
-
"enabled": true,
|
|
63
|
-
"scripts": {
|
|
64
|
-
"format": "oxfmt .",
|
|
65
|
-
"format:check": "oxfmt --check .",
|
|
66
|
-
"lint": "oxlint .",
|
|
67
|
-
"lint:fix": "oxlint --fix ."
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
49
|
"typecheckScript": {
|
|
71
|
-
"enabled": true,
|
|
72
50
|
"modes": {
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"testScriptConsistency": {
|
|
77
|
-
"enabled": true,
|
|
78
|
-
"testFilePattern": "**/*.test.{ts,tsx,js,jsx}",
|
|
79
|
-
"testScript": "bun test",
|
|
80
|
-
"allWithoutTests": "bun run format:check && bun run lint && bun run typecheck && bun run warden",
|
|
81
|
-
"allWithTests": "bun run format:check && bun run lint && bun run typecheck && bun run warden && bun run test"
|
|
82
|
-
},
|
|
83
|
-
"tailwindOxfmtConfig": {
|
|
84
|
-
"enabled": true
|
|
85
|
-
},
|
|
86
|
-
"portlessNextDev": {
|
|
87
|
-
"enabled": true,
|
|
88
|
-
"version": "0.13.0"
|
|
89
|
-
},
|
|
90
|
-
"nextConfig": {
|
|
91
|
-
"enabled": true,
|
|
92
|
-
"options": {
|
|
93
|
-
"reactStrictMode": true,
|
|
94
|
-
"reactCompiler": true,
|
|
95
|
-
"output": "export"
|
|
96
|
-
},
|
|
97
|
-
"devDependencies": {
|
|
98
|
-
"babel-plugin-react-compiler": "1.0.0"
|
|
51
|
+
"svg-to-tsl": "bun-filter-all",
|
|
52
|
+
"manalab": "bun-filter-all",
|
|
53
|
+
"voidcore": "bun-filter-all"
|
|
99
54
|
}
|
|
100
|
-
},
|
|
101
|
-
"wardenScript": {
|
|
102
|
-
"enabled": true,
|
|
103
|
-
"package": "@verekia/warden"
|
|
104
|
-
},
|
|
105
|
-
"ciWorkflow": {
|
|
106
|
-
"enabled": true,
|
|
107
|
-
"file": ".github/workflows/ci.yml",
|
|
108
|
-
"runs": "bun run all"
|
|
109
55
|
}
|
|
110
56
|
}
|
|
111
57
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { loadConfig, resolveProjects } from './config.ts'
|
|
5
|
+
|
|
6
|
+
const baseDir = process.cwd()
|
|
7
|
+
|
|
8
|
+
const config = await loadConfig(baseDir)
|
|
9
|
+
const allProjects = await resolveProjects(config, baseDir)
|
|
10
|
+
|
|
11
|
+
const filterNames = process.argv.slice(2)
|
|
12
|
+
const knownNames = new Set(allProjects.map(p => p.name))
|
|
13
|
+
const unknown = filterNames.filter(n => !knownNames.has(n))
|
|
14
|
+
if (unknown.length > 0) {
|
|
15
|
+
console.error(
|
|
16
|
+
`Unknown project(s): ${unknown.join(', ')}\nConfigured projects: ${allProjects.map(p => p.name).join(', ')}`,
|
|
17
|
+
)
|
|
18
|
+
process.exit(2)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const projects = filterNames.length > 0 ? allProjects.filter(p => filterNames.includes(p.name)) : allProjects
|
|
22
|
+
|
|
23
|
+
const missing = projects.filter(p => !existsSync(p.path))
|
|
24
|
+
if (missing.length > 0) {
|
|
25
|
+
const detail = missing.map(p => ` - ${p.name} (expected at ${p.path})`).join('\n')
|
|
26
|
+
const noun = missing.length === 1 ? 'directory is' : 'directories are'
|
|
27
|
+
console.error(`Cannot run all-siblings — the following project ${noun} not on disk:\n${detail}`)
|
|
28
|
+
process.exit(2)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(
|
|
32
|
+
`all-siblings — running "bun run all" in ${projects.length} project(s): ${projects.map(p => p.name).join(', ')}`,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const failed: string[] = []
|
|
36
|
+
for (const project of projects) {
|
|
37
|
+
console.log(`\n=== ${project.name} ===`)
|
|
38
|
+
const proc = Bun.spawn(['bun', 'run', 'all'], {
|
|
39
|
+
cwd: project.path,
|
|
40
|
+
stdout: 'inherit',
|
|
41
|
+
stderr: 'inherit',
|
|
42
|
+
})
|
|
43
|
+
const exitCode = await proc.exited
|
|
44
|
+
if (exitCode !== 0) {
|
|
45
|
+
failed.push(project.name)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(`\n${projects.length - failed.length}/${projects.length} projects passed`)
|
|
50
|
+
if (failed.length > 0) {
|
|
51
|
+
console.log(`Failed: ${failed.join(', ')}`)
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckResult, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, Project, WardenConfig } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
type Step = { run?: unknown }
|
|
4
4
|
type Job = { steps?: unknown }
|
|
@@ -23,8 +23,7 @@ export const ciWorkflow = async (
|
|
|
23
23
|
projects: Project[],
|
|
24
24
|
options: NonNullable<NonNullable<WardenConfig['checks']>['ciWorkflow']>,
|
|
25
25
|
): Promise<CheckResult> => {
|
|
26
|
-
const
|
|
27
|
-
let passed = true
|
|
26
|
+
const findings: Finding[] = []
|
|
28
27
|
|
|
29
28
|
const file = options.file ?? DEFAULT_FILE
|
|
30
29
|
const runs = options.runs ?? DEFAULT_RUNS
|
|
@@ -32,8 +31,7 @@ export const ciWorkflow = async (
|
|
|
32
31
|
for (const project of projects) {
|
|
33
32
|
const handle = Bun.file(`${project.path}/${file}`)
|
|
34
33
|
if (!(await handle.exists())) {
|
|
35
|
-
|
|
36
|
-
messages.push(`${project.name}: missing ${file}`)
|
|
34
|
+
findings.push({ project: project.name, message: `missing ${file}` })
|
|
37
35
|
continue
|
|
38
36
|
}
|
|
39
37
|
if (!runs) continue
|
|
@@ -42,17 +40,15 @@ export const ciWorkflow = async (
|
|
|
42
40
|
try {
|
|
43
41
|
workflow = Bun.YAML.parse(await handle.text()) as Workflow
|
|
44
42
|
} catch (err) {
|
|
45
|
-
|
|
46
|
-
messages.push(`${project.name}: ${file} is not valid YAML (${(err as Error).message})`)
|
|
43
|
+
findings.push({ project: project.name, message: `${file} is not valid YAML (${(err as Error).message})` })
|
|
47
44
|
continue
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
const commands = collectRunCommands(workflow)
|
|
51
48
|
if (!commands.some(cmd => cmd.includes(runs))) {
|
|
52
|
-
|
|
53
|
-
messages.push(`${project.name}: ${file} has no step running ${JSON.stringify(runs)}`)
|
|
49
|
+
findings.push({ project: project.name, message: `${file} has no step running ${JSON.stringify(runs)}` })
|
|
54
50
|
}
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
return { name: 'ciWorkflow',
|
|
53
|
+
return { name: 'ciWorkflow', findings }
|
|
58
54
|
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import type { CheckResult, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, Project, WardenConfig } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_FILES = ['.oxfmtrc.json', '.oxlintrc.json']
|
|
2
4
|
|
|
3
5
|
export const configFilesPresent = async (
|
|
4
6
|
projects: Project[],
|
|
5
7
|
options: NonNullable<NonNullable<WardenConfig['checks']>['configFilesPresent']>,
|
|
6
8
|
): Promise<CheckResult> => {
|
|
7
|
-
const
|
|
8
|
-
let passed = true
|
|
9
|
+
const findings: Finding[] = []
|
|
9
10
|
|
|
10
|
-
const files = options.files ??
|
|
11
|
+
const files = options.files ?? DEFAULT_FILES
|
|
11
12
|
for (const project of projects) {
|
|
12
13
|
for (const file of files) {
|
|
13
14
|
const exists = await Bun.file(`${project.path}/${file}`).exists()
|
|
14
15
|
if (!exists) {
|
|
15
|
-
|
|
16
|
-
messages.push(`${project.name}: missing ${file}`)
|
|
16
|
+
findings.push({ project: project.name, message: `missing ${file}` })
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
return { name: 'configFilesPresent',
|
|
21
|
+
return { name: 'configFilesPresent', findings }
|
|
22
22
|
}
|
|
@@ -1,48 +1,49 @@
|
|
|
1
|
-
import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
const findVersion = (pkg: PackageJson, name: string): string | undefined =>
|
|
4
4
|
pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]
|
|
5
5
|
|
|
6
6
|
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/
|
|
7
7
|
|
|
8
|
+
const DEFAULT_PACKAGES: Record<string, string> = {
|
|
9
|
+
oxfmt: '0.48.0',
|
|
10
|
+
oxlint: '1.63.0',
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export const exactDependencyVersions = (
|
|
9
14
|
projects: Project[],
|
|
10
15
|
options: NonNullable<NonNullable<WardenConfig['checks']>['exactDependencyVersions']>,
|
|
11
16
|
): CheckResult => {
|
|
12
|
-
const
|
|
13
|
-
let passed = true
|
|
17
|
+
const findings: Finding[] = []
|
|
14
18
|
|
|
15
|
-
const packages = Object.entries(options.packages ??
|
|
19
|
+
const packages = Object.entries(options.packages ?? DEFAULT_PACKAGES)
|
|
16
20
|
if (packages.length === 0) {
|
|
17
|
-
return { name: 'exactDependencyVersions',
|
|
21
|
+
return { name: 'exactDependencyVersions', findings }
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
for (const project of projects) {
|
|
21
25
|
if (!project.packageJson) {
|
|
22
|
-
|
|
23
|
-
messages.push(`${project.name}: no package.json`)
|
|
26
|
+
findings.push({ project: project.name, message: 'no package.json' })
|
|
24
27
|
continue
|
|
25
28
|
}
|
|
26
29
|
for (const [name, expected] of packages) {
|
|
27
30
|
const declared = findVersion(project.packageJson, name)
|
|
28
31
|
if (declared === undefined) {
|
|
29
|
-
|
|
30
|
-
messages.push(`${project.name}: ${name} not declared (expected ${expected})`)
|
|
32
|
+
findings.push({ project: project.name, message: `${name} not declared (expected ${expected})` })
|
|
31
33
|
continue
|
|
32
34
|
}
|
|
33
35
|
if (!EXACT_SEMVER.test(declared)) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
36
|
+
findings.push({
|
|
37
|
+
project: project.name,
|
|
38
|
+
message: `${name} ${JSON.stringify(declared)} is not an exact version — use "${expected}"`,
|
|
39
|
+
})
|
|
38
40
|
continue
|
|
39
41
|
}
|
|
40
42
|
if (declared !== expected) {
|
|
41
|
-
|
|
42
|
-
messages.push(`${project.name}: ${name} ${declared} does not match expected ${expected}`)
|
|
43
|
+
findings.push({ project: project.name, message: `${name} ${declared} does not match expected ${expected}` })
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
return { name: 'exactDependencyVersions',
|
|
48
|
+
return { name: 'exactDependencyVersions', findings }
|
|
48
49
|
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
const findVersion = (pkg: PackageJson, name: string): string | undefined =>
|
|
4
4
|
pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]
|
|
5
5
|
|
|
6
|
+
const DEFAULT_PACKAGES = ['oxfmt', 'oxlint']
|
|
7
|
+
|
|
6
8
|
export const matchingDependencyVersions = (
|
|
7
9
|
projects: Project[],
|
|
8
10
|
options: NonNullable<NonNullable<WardenConfig['checks']>['matchingDependencyVersions']>,
|
|
9
11
|
): CheckResult => {
|
|
10
|
-
const
|
|
11
|
-
let passed = true
|
|
12
|
+
const findings: Finding[] = []
|
|
12
13
|
|
|
13
|
-
for (const packageName of options.packages ??
|
|
14
|
+
for (const packageName of options.packages ?? DEFAULT_PACKAGES) {
|
|
14
15
|
const versionToProjects = new Map<string, string[]>()
|
|
15
16
|
const missing: string[] = []
|
|
16
17
|
|
|
@@ -25,19 +26,26 @@ export const matchingDependencyVersions = (
|
|
|
25
26
|
versionToProjects.set(version, existing)
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
messages.push(`${packageName}: not declared in ${missing.join(', ')}`)
|
|
29
|
+
for (const project of missing) {
|
|
30
|
+
findings.push({ project, message: `${packageName}: not declared` })
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (versionToProjects.size > 1) {
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
const sorted = [...versionToProjects.entries()].toSorted((a, b) => b[1].length - a[1].length)
|
|
35
|
+
const [majority, ...rest] = sorted
|
|
36
|
+
if (majority) {
|
|
37
|
+
const [majorityVersion, majorityNames] = majority
|
|
38
|
+
for (const [version, names] of rest) {
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
findings.push({
|
|
41
|
+
project: name,
|
|
42
|
+
message: `${packageName} ${version} mismatches majority ${majorityVersion} (${majorityNames.join(', ')})`,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
return { name: 'matchingDependencyVersions',
|
|
50
|
+
return { name: 'matchingDependencyVersions', findings }
|
|
43
51
|
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { Glob } from 'bun'
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
4
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
5
5
|
|
|
6
6
|
const NEXT = 'next'
|
|
7
7
|
const CONFIG_VARIANTS = ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'] as const
|
|
8
8
|
|
|
9
|
+
const DEFAULT_OPTIONS: Record<string, unknown> = {
|
|
10
|
+
reactStrictMode: true,
|
|
11
|
+
reactCompiler: true,
|
|
12
|
+
output: 'export',
|
|
13
|
+
}
|
|
14
|
+
const DEFAULT_DEV_DEPENDENCIES: Record<string, string> = {
|
|
15
|
+
'babel-plugin-react-compiler': '1.0.0',
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
type WorkspaceField = string[] | { packages?: string[] } | undefined
|
|
10
19
|
|
|
11
20
|
const dependsOnNext = (pkg: PackageJson): boolean =>
|
|
@@ -68,8 +77,7 @@ export const nextConfig = async (
|
|
|
68
77
|
projects: Project[],
|
|
69
78
|
options: NonNullable<NonNullable<WardenConfig['checks']>['nextConfig']>,
|
|
70
79
|
): Promise<CheckResult> => {
|
|
71
|
-
const
|
|
72
|
-
let passed = true
|
|
80
|
+
const findings: Finding[] = []
|
|
73
81
|
|
|
74
82
|
for (const project of projects) {
|
|
75
83
|
const pkgs = await collectPackageJsons(project)
|
|
@@ -92,44 +100,43 @@ export const nextConfig = async (
|
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
if (configRel === null || configAbs === null) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`${
|
|
98
|
-
)
|
|
103
|
+
findings.push({
|
|
104
|
+
project: project.name,
|
|
105
|
+
message: `${relPath}: declares "next" but no config file found at ${dirLabel || './'} (expected one of ${CONFIG_VARIANTS.join(', ')})`,
|
|
106
|
+
})
|
|
99
107
|
} else {
|
|
100
108
|
let cfg: Record<string, unknown> | null = null
|
|
101
109
|
try {
|
|
102
110
|
cfg = await loadConfigObject(configAbs)
|
|
103
111
|
} catch (err) {
|
|
104
|
-
|
|
105
|
-
messages.push(`${project.name}/${configRel}: failed to load — ${(err as Error).message}`)
|
|
112
|
+
findings.push({ project: project.name, message: `${configRel}: failed to load — ${(err as Error).message}` })
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
if (cfg) {
|
|
109
|
-
for (const [key, expected] of Object.entries(options.options ??
|
|
116
|
+
for (const [key, expected] of Object.entries(options.options ?? DEFAULT_OPTIONS)) {
|
|
110
117
|
if (!equal(cfg[key], expected)) {
|
|
111
|
-
passed = false
|
|
112
118
|
const actual = key in cfg ? JSON.stringify(cfg[key]) : 'missing'
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
findings.push({
|
|
120
|
+
project: project.name,
|
|
121
|
+
message: `${configRel}: "${key}" is ${actual} — expected ${JSON.stringify(expected)}`,
|
|
122
|
+
})
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
for (const [name, expected] of Object.entries(options.devDependencies ??
|
|
128
|
+
for (const [name, expected] of Object.entries(options.devDependencies ?? DEFAULT_DEV_DEPENDENCIES)) {
|
|
122
129
|
const declared = pkg.devDependencies?.[name]
|
|
123
130
|
if (declared !== expected) {
|
|
124
131
|
const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`${
|
|
128
|
-
)
|
|
132
|
+
findings.push({
|
|
133
|
+
project: project.name,
|
|
134
|
+
message: `${relPath}: declares "next" but devDependencies["${name}"] is ${actual} — expected exact "${expected}" (must be declared in this package.json)`,
|
|
135
|
+
})
|
|
129
136
|
}
|
|
130
137
|
}
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
return { name: 'nextConfig',
|
|
141
|
+
return { name: 'nextConfig', findings }
|
|
135
142
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Glob } from 'bun'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PACKAGES: Record<string, string> = {
|
|
9
|
+
next: '16.2.6',
|
|
10
|
+
react: '19.2.6',
|
|
11
|
+
'react-dom': '19.2.6',
|
|
12
|
+
'@types/react': '19.2.14',
|
|
13
|
+
'@types/react-dom': '19.2.3',
|
|
14
|
+
tailwindcss: '4.3.0',
|
|
15
|
+
typescript: '6.0.3',
|
|
16
|
+
postcss: '8.5.14',
|
|
17
|
+
'@types/node': '25.7.0',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type WorkspaceField = string[] | { packages?: string[] } | undefined
|
|
21
|
+
|
|
22
|
+
const workspacePatterns = (pkg: PackageJson | null): string[] => {
|
|
23
|
+
const ws = (pkg as { workspaces?: WorkspaceField } | null)?.workspaces
|
|
24
|
+
if (Array.isArray(ws)) return ws
|
|
25
|
+
if (ws && Array.isArray(ws.packages)) return ws.packages
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const collectPackageJsons = async (project: Project): Promise<Array<{ relPath: string; pkg: PackageJson }>> => {
|
|
30
|
+
const results: Array<{ relPath: string; pkg: PackageJson }> = []
|
|
31
|
+
if (project.packageJson) {
|
|
32
|
+
results.push({ relPath: 'package.json', pkg: project.packageJson })
|
|
33
|
+
}
|
|
34
|
+
for (const pattern of workspacePatterns(project.packageJson)) {
|
|
35
|
+
const target = `${pattern}/package.json`
|
|
36
|
+
if (pattern.includes('*')) {
|
|
37
|
+
const glob = new Glob(target)
|
|
38
|
+
for await (const path of glob.scan({ cwd: project.path, onlyFiles: true })) {
|
|
39
|
+
if (path.startsWith('node_modules/')) continue
|
|
40
|
+
const pkgFile = Bun.file(resolve(project.path, path))
|
|
41
|
+
if (await pkgFile.exists()) {
|
|
42
|
+
results.push({ relPath: path, pkg: (await pkgFile.json()) as PackageJson })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
const pkgFile = Bun.file(resolve(project.path, target))
|
|
47
|
+
if (await pkgFile.exists()) {
|
|
48
|
+
results.push({ relPath: target, pkg: (await pkgFile.json()) as PackageJson })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return results
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEP_GROUPS = ['dependencies', 'devDependencies', 'peerDependencies'] as const
|
|
56
|
+
|
|
57
|
+
export const pinnedDependencyVersions = async (
|
|
58
|
+
projects: Project[],
|
|
59
|
+
options: NonNullable<NonNullable<WardenConfig['checks']>['pinnedDependencyVersions']>,
|
|
60
|
+
): Promise<CheckResult> => {
|
|
61
|
+
const findings: Finding[] = []
|
|
62
|
+
const packages = Object.entries(options.packages ?? DEFAULT_PACKAGES)
|
|
63
|
+
if (packages.length === 0) {
|
|
64
|
+
return { name: 'pinnedDependencyVersions', findings }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const project of projects) {
|
|
68
|
+
const pkgs = await collectPackageJsons(project)
|
|
69
|
+
|
|
70
|
+
for (const { relPath, pkg } of pkgs) {
|
|
71
|
+
for (const [name, expected] of packages) {
|
|
72
|
+
for (const group of DEP_GROUPS) {
|
|
73
|
+
const declared = pkg[group]?.[name]
|
|
74
|
+
if (declared === undefined) continue
|
|
75
|
+
if (!EXACT_SEMVER.test(declared)) {
|
|
76
|
+
findings.push({
|
|
77
|
+
project: project.name,
|
|
78
|
+
message: `${relPath}: ${group}["${name}"] is ${JSON.stringify(declared)} — must be exact "${expected}"`,
|
|
79
|
+
})
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (declared !== expected) {
|
|
83
|
+
findings.push({
|
|
84
|
+
project: project.name,
|
|
85
|
+
message: `${relPath}: ${group}["${name}"] is ${declared} — expected ${expected}`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { name: 'pinnedDependencyVersions', findings }
|
|
94
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Glob } from 'bun'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
4
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
5
5
|
|
|
6
6
|
const NEXT_DEV_RE = /\bnext dev\b/
|
|
7
7
|
const PORTLESS_WRAP_RE = /\bportless\s+\S+\s+next dev\b/
|
|
8
|
+
const DEFAULT_VERSION = '0.13.0'
|
|
8
9
|
|
|
9
10
|
type WorkspaceField = string[] | { packages?: string[] } | undefined
|
|
10
11
|
|
|
@@ -45,8 +46,7 @@ export const portlessNextDev = async (
|
|
|
45
46
|
projects: Project[],
|
|
46
47
|
options: NonNullable<NonNullable<WardenConfig['checks']>['portlessNextDev']>,
|
|
47
48
|
): Promise<CheckResult> => {
|
|
48
|
-
const
|
|
49
|
-
let passed = true
|
|
49
|
+
const findings: Finding[] = []
|
|
50
50
|
|
|
51
51
|
for (const project of projects) {
|
|
52
52
|
const pkgs = await collectPackageJsons(project)
|
|
@@ -59,25 +59,26 @@ export const portlessNextDev = async (
|
|
|
59
59
|
if (!NEXT_DEV_RE.test(value)) continue
|
|
60
60
|
usesNextDev = true
|
|
61
61
|
if (!PORTLESS_WRAP_RE.test(value)) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`${
|
|
65
|
-
)
|
|
62
|
+
findings.push({
|
|
63
|
+
project: project.name,
|
|
64
|
+
message: `${relPath}: script "${name}" is ${JSON.stringify(value)} — must be wrapped as "portless <app-name> next dev"`,
|
|
65
|
+
})
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
if (usesNextDev
|
|
69
|
+
if (usesNextDev) {
|
|
70
|
+
const expectedVersion = options.version ?? DEFAULT_VERSION
|
|
70
71
|
const declared = pkg.devDependencies?.portless
|
|
71
|
-
if (declared !==
|
|
72
|
+
if (declared !== expectedVersion) {
|
|
72
73
|
const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
`${
|
|
76
|
-
)
|
|
74
|
+
findings.push({
|
|
75
|
+
project: project.name,
|
|
76
|
+
message: `${relPath}: uses "next dev" but devDependencies.portless is ${actual} — expected exact "${expectedVersion}" (must be declared in this package.json)`,
|
|
77
|
+
})
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
return { name: 'portlessNextDev',
|
|
83
|
+
return { name: 'portlessNextDev', findings }
|
|
83
84
|
}
|
|
@@ -1,37 +1,44 @@
|
|
|
1
|
-
import type { CheckResult, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, Project, WardenConfig } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SCRIPTS: Record<string, string> = {
|
|
4
|
+
format: 'oxfmt .',
|
|
5
|
+
'format:check': 'oxfmt --check .',
|
|
6
|
+
lint: 'oxlint .',
|
|
7
|
+
'lint:fix': 'oxlint --fix .',
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
export const requiredScripts = (
|
|
4
11
|
projects: Project[],
|
|
5
12
|
options: NonNullable<NonNullable<WardenConfig['checks']>['requiredScripts']>,
|
|
6
13
|
): CheckResult => {
|
|
7
|
-
const
|
|
8
|
-
let passed = true
|
|
14
|
+
const findings: Finding[] = []
|
|
9
15
|
|
|
10
|
-
const required = Object.entries(options.scripts ??
|
|
16
|
+
const required = Object.entries(options.scripts ?? DEFAULT_SCRIPTS)
|
|
11
17
|
if (required.length === 0) {
|
|
12
|
-
return { name: 'requiredScripts',
|
|
18
|
+
return { name: 'requiredScripts', findings }
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
for (const project of projects) {
|
|
16
22
|
const scripts = project.packageJson?.scripts
|
|
17
23
|
if (!scripts) {
|
|
18
|
-
|
|
19
|
-
messages.push(`${project.name}: no "scripts" in package.json`)
|
|
24
|
+
findings.push({ project: project.name, message: 'no "scripts" in package.json' })
|
|
20
25
|
continue
|
|
21
26
|
}
|
|
22
27
|
for (const [name, expected] of required) {
|
|
23
28
|
const actual = scripts[name]
|
|
24
29
|
if (actual === undefined) {
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
findings.push({
|
|
31
|
+
project: project.name,
|
|
32
|
+
message: `missing script "${name}" (expected ${JSON.stringify(expected)})`,
|
|
33
|
+
})
|
|
27
34
|
} else if (actual !== expected) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
35
|
+
findings.push({
|
|
36
|
+
project: project.name,
|
|
37
|
+
message: `script "${name}" is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
|
|
38
|
+
})
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
return { name: 'requiredScripts',
|
|
43
|
+
return { name: 'requiredScripts', findings }
|
|
37
44
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
const TAILWIND = 'tailwindcss'
|
|
4
4
|
const OXFMT_CONFIG = '.oxfmtrc.json'
|
|
@@ -13,8 +13,7 @@ export const tailwindOxfmtConfig = async (
|
|
|
13
13
|
projects: Project[],
|
|
14
14
|
_options: NonNullable<NonNullable<WardenConfig['checks']>['tailwindOxfmtConfig']>,
|
|
15
15
|
): Promise<CheckResult> => {
|
|
16
|
-
const
|
|
17
|
-
let passed = true
|
|
16
|
+
const findings: Finding[] = []
|
|
18
17
|
|
|
19
18
|
for (const project of projects) {
|
|
20
19
|
const oxfmtFile = Bun.file(`${project.path}/${OXFMT_CONFIG}`)
|
|
@@ -24,18 +23,20 @@ export const tailwindOxfmtConfig = async (
|
|
|
24
23
|
const present = REQUIRED_KEY in oxfmt
|
|
25
24
|
|
|
26
25
|
if (present && oxfmt[REQUIRED_KEY] !== true) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
26
|
+
findings.push({
|
|
27
|
+
project: project.name,
|
|
28
|
+
message: `${OXFMT_CONFIG} "${REQUIRED_KEY}" must be \`true\` (got ${JSON.stringify(oxfmt[REQUIRED_KEY])})`,
|
|
29
|
+
})
|
|
31
30
|
continue
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
if (!present && project.packageJson && dependsOnTailwind(project.packageJson)) {
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
findings.push({
|
|
35
|
+
project: project.name,
|
|
36
|
+
message: `depends on ${TAILWIND} but ${OXFMT_CONFIG} is missing "${REQUIRED_KEY}": true`,
|
|
37
|
+
})
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
return { name: 'tailwindOxfmtConfig',
|
|
41
|
+
return { name: 'tailwindOxfmtConfig', findings }
|
|
41
42
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Glob } from 'bun'
|
|
2
2
|
|
|
3
|
-
import type { CheckResult, Project, WardenConfig } from '../types.ts'
|
|
3
|
+
import type { CheckResult, Finding, Project, WardenConfig } from '../types.ts'
|
|
4
4
|
|
|
5
5
|
const projectHasTests = async (projectPath: string, pattern: string): Promise<boolean> => {
|
|
6
6
|
const glob = new Glob(pattern)
|
|
@@ -13,16 +13,20 @@ const projectHasTests = async (projectPath: string, pattern: string): Promise<bo
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_TEST_FILE_PATTERN = '**/*.test.{ts,tsx,js,jsx}'
|
|
15
15
|
const DEFAULT_TEST_SCRIPT = 'bun test'
|
|
16
|
+
const DEFAULT_ALL_WITHOUT_TESTS = 'bun run format:check && bun run lint && bun run typecheck && bun run warden'
|
|
17
|
+
const DEFAULT_ALL_WITH_TESTS =
|
|
18
|
+
'bun run format:check && bun run lint && bun run typecheck && bun run warden && bun run test'
|
|
16
19
|
|
|
17
20
|
export const testScriptConsistency = async (
|
|
18
21
|
projects: Project[],
|
|
19
22
|
options: NonNullable<NonNullable<WardenConfig['checks']>['testScriptConsistency']>,
|
|
20
23
|
): Promise<CheckResult> => {
|
|
21
|
-
const
|
|
22
|
-
let passed = true
|
|
24
|
+
const findings: Finding[] = []
|
|
23
25
|
|
|
24
26
|
const testFilePattern = options.testFilePattern ?? DEFAULT_TEST_FILE_PATTERN
|
|
25
27
|
const testScript = options.testScript ?? DEFAULT_TEST_SCRIPT
|
|
28
|
+
const allWithTests = options.allWithTests ?? DEFAULT_ALL_WITH_TESTS
|
|
29
|
+
const allWithoutTests = options.allWithoutTests ?? DEFAULT_ALL_WITHOUT_TESTS
|
|
26
30
|
|
|
27
31
|
for (const project of projects) {
|
|
28
32
|
const scripts = project.packageJson?.scripts ?? {}
|
|
@@ -30,26 +34,26 @@ export const testScriptConsistency = async (
|
|
|
30
34
|
|
|
31
35
|
if (hasTests) {
|
|
32
36
|
if (scripts.test !== testScript) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
+
findings.push({
|
|
38
|
+
project: project.name,
|
|
39
|
+
message: `has test files but "test" script is ${JSON.stringify(scripts.test)}, expected ${JSON.stringify(testScript)}`,
|
|
40
|
+
})
|
|
37
41
|
}
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
42
|
+
if (scripts.all !== allWithTests) {
|
|
43
|
+
findings.push({
|
|
44
|
+
project: project.name,
|
|
45
|
+
message: `"all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(allWithTests)} (project has tests)`,
|
|
46
|
+
})
|
|
43
47
|
}
|
|
44
48
|
} else {
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
49
|
+
if (scripts.all !== allWithoutTests) {
|
|
50
|
+
findings.push({
|
|
51
|
+
project: project.name,
|
|
52
|
+
message: `"all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(allWithoutTests)} (no tests detected)`,
|
|
53
|
+
})
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
return { name: 'testScriptConsistency',
|
|
58
|
+
return { name: 'testScriptConsistency', findings }
|
|
55
59
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckResult, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, Project, WardenConfig } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
const SINGLE = 'tsc --noEmit'
|
|
4
4
|
const SOLUTION = 'tsc -b --noEmit'
|
|
@@ -19,14 +19,12 @@ export const typecheckScript = async (
|
|
|
19
19
|
projects: Project[],
|
|
20
20
|
options: NonNullable<NonNullable<WardenConfig['checks']>['typecheckScript']>,
|
|
21
21
|
): Promise<CheckResult> => {
|
|
22
|
-
const
|
|
23
|
-
let passed = true
|
|
22
|
+
const findings: Finding[] = []
|
|
24
23
|
|
|
25
24
|
for (const project of projects) {
|
|
26
25
|
const scripts = project.packageJson?.scripts
|
|
27
26
|
if (!scripts) {
|
|
28
|
-
|
|
29
|
-
messages.push(`${project.name}: no "scripts" in package.json`)
|
|
27
|
+
findings.push({ project: project.name, message: 'no "scripts" in package.json' })
|
|
30
28
|
continue
|
|
31
29
|
}
|
|
32
30
|
const mode = options.modes?.[project.name] ?? 'auto'
|
|
@@ -38,15 +36,17 @@ export const typecheckScript = async (
|
|
|
38
36
|
: `root tsconfig ${expected === SOLUTION ? 'has' : 'has no'} project references`
|
|
39
37
|
const actual = scripts.typecheck
|
|
40
38
|
if (actual === undefined) {
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
findings.push({
|
|
40
|
+
project: project.name,
|
|
41
|
+
message: `missing script "typecheck" (expected ${JSON.stringify(expected)})`,
|
|
42
|
+
})
|
|
43
43
|
} else if (actual !== expected) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
44
|
+
findings.push({
|
|
45
|
+
project: project.name,
|
|
46
|
+
message: `script "typecheck" is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)} (${reason})`,
|
|
47
|
+
})
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
return { name: 'typecheckScript',
|
|
51
|
+
return { name: 'typecheckScript', findings }
|
|
52
52
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckResult, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
1
|
+
import type { CheckResult, Finding, PackageJson, Project, WardenConfig } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
const dependsOn = (pkg: PackageJson, name: string): boolean =>
|
|
4
4
|
name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {}) || name in (pkg.peerDependencies ?? {})
|
|
@@ -9,8 +9,7 @@ export const wardenScript = (
|
|
|
9
9
|
projects: Project[],
|
|
10
10
|
options: NonNullable<NonNullable<WardenConfig['checks']>['wardenScript']>,
|
|
11
11
|
): CheckResult => {
|
|
12
|
-
const
|
|
13
|
-
let passed = true
|
|
12
|
+
const findings: Finding[] = []
|
|
14
13
|
const packageName = options.package ?? DEFAULT_PACKAGE
|
|
15
14
|
|
|
16
15
|
for (const project of projects) {
|
|
@@ -19,18 +18,18 @@ export const wardenScript = (
|
|
|
19
18
|
|
|
20
19
|
const scripts = pkg.scripts ?? {}
|
|
21
20
|
if (scripts.warden !== 'warden') {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
21
|
+
findings.push({
|
|
22
|
+
project: project.name,
|
|
23
|
+
message: `depends on ${packageName} but "warden" script is ${JSON.stringify(scripts.warden)}, expected "warden"`,
|
|
24
|
+
})
|
|
26
25
|
}
|
|
27
26
|
if (!scripts.all || !scripts.all.includes('bun run warden')) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
27
|
+
findings.push({
|
|
28
|
+
project: project.name,
|
|
29
|
+
message: `depends on ${packageName} but "all" script ${JSON.stringify(scripts.all)} is missing "bun run warden"`,
|
|
30
|
+
})
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
return { name: 'wardenScript',
|
|
34
|
+
return { name: 'wardenScript', findings }
|
|
36
35
|
}
|
package/src/ci-status.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { loadConfig, resolveProjects } from './config.ts'
|
|
3
|
+
|
|
4
|
+
type Run = {
|
|
5
|
+
conclusion: string | null
|
|
6
|
+
status: string
|
|
7
|
+
workflowName: string
|
|
8
|
+
url: string
|
|
9
|
+
createdAt: string
|
|
10
|
+
headSha: string
|
|
11
|
+
event: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const baseDir = process.cwd()
|
|
15
|
+
|
|
16
|
+
const config = await loadConfig(baseDir)
|
|
17
|
+
const allProjects = await resolveProjects(config, baseDir)
|
|
18
|
+
|
|
19
|
+
const filterNames = process.argv.slice(2)
|
|
20
|
+
const knownNames = new Set(allProjects.map(p => p.name))
|
|
21
|
+
const unknown = filterNames.filter(n => !knownNames.has(n))
|
|
22
|
+
if (unknown.length > 0) {
|
|
23
|
+
console.error(
|
|
24
|
+
`Unknown project(s): ${unknown.join(', ')}\nConfigured projects: ${allProjects.map(p => p.name).join(', ')}`,
|
|
25
|
+
)
|
|
26
|
+
process.exit(2)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const projects = filterNames.length > 0 ? allProjects.filter(p => filterNames.includes(p.name)) : allProjects
|
|
30
|
+
|
|
31
|
+
console.log(
|
|
32
|
+
`ci-status — checking last main-branch run for ${projects.length} project(s): ${projects.map(p => p.name).join(', ')}`,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const failed: string[] = []
|
|
36
|
+
const missing: string[] = []
|
|
37
|
+
|
|
38
|
+
for (const project of projects) {
|
|
39
|
+
const repo = `verekia/${project.name}`
|
|
40
|
+
const proc = Bun.spawn(
|
|
41
|
+
[
|
|
42
|
+
'gh',
|
|
43
|
+
'run',
|
|
44
|
+
'list',
|
|
45
|
+
'--repo',
|
|
46
|
+
repo,
|
|
47
|
+
'--branch',
|
|
48
|
+
'main',
|
|
49
|
+
'--limit',
|
|
50
|
+
'1',
|
|
51
|
+
'--json',
|
|
52
|
+
'conclusion,status,workflowName,url,createdAt,headSha,event',
|
|
53
|
+
],
|
|
54
|
+
{ stdout: 'pipe', stderr: 'pipe' },
|
|
55
|
+
)
|
|
56
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
57
|
+
new Response(proc.stdout).text(),
|
|
58
|
+
new Response(proc.stderr).text(),
|
|
59
|
+
proc.exited,
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
if (exitCode !== 0) {
|
|
63
|
+
console.log(`\n[ERROR] ${project.name} (${repo})`)
|
|
64
|
+
console.log(stderr.trim() || `gh exited with code ${exitCode}`)
|
|
65
|
+
failed.push(project.name)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const runs = JSON.parse(stdout) as Run[]
|
|
70
|
+
const run = runs[0]
|
|
71
|
+
if (!run) {
|
|
72
|
+
console.log(`\n[NONE] ${project.name} — no workflow runs on main`)
|
|
73
|
+
missing.push(project.name)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const verdict =
|
|
78
|
+
run.status !== 'completed'
|
|
79
|
+
? `IN PROGRESS (${run.status})`
|
|
80
|
+
: run.conclusion === 'success'
|
|
81
|
+
? 'PASS'
|
|
82
|
+
: `FAIL (${run.conclusion ?? 'unknown'})`
|
|
83
|
+
const tag = verdict === 'PASS' ? '[PASS]' : verdict.startsWith('IN PROGRESS') ? '[WAIT]' : '[FAIL]'
|
|
84
|
+
|
|
85
|
+
console.log(`\n${tag} ${project.name} — ${run.workflowName}: ${verdict}`)
|
|
86
|
+
console.log(` ${run.createdAt} ${run.headSha.slice(0, 7)} ${run.event}`)
|
|
87
|
+
console.log(` ${run.url}`)
|
|
88
|
+
|
|
89
|
+
if (tag === '[FAIL]') failed.push(project.name)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const passed = projects.length - failed.length - missing.length
|
|
93
|
+
console.log(`\n${passed}/${projects.length} projects passed`)
|
|
94
|
+
if (missing.length > 0) console.log(`No runs on main: ${missing.join(', ')}`)
|
|
95
|
+
if (failed.length > 0) {
|
|
96
|
+
console.log(`Failed: ${failed.join(', ')}`)
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { configFilesPresent } from './checks/config-files-present.ts'
|
|
|
6
6
|
import { exactDependencyVersions } from './checks/exact-dependency-versions.ts'
|
|
7
7
|
import { matchingDependencyVersions } from './checks/matching-dependency-versions.ts'
|
|
8
8
|
import { nextConfig } from './checks/next-config.ts'
|
|
9
|
+
import { pinnedDependencyVersions } from './checks/pinned-dependency-versions.ts'
|
|
9
10
|
import { portlessNextDev } from './checks/portless-next-dev.ts'
|
|
10
11
|
import { requiredScripts } from './checks/required-scripts.ts'
|
|
11
12
|
import { tailwindOxfmtConfig } from './checks/tailwind-oxfmt-config.ts'
|
|
@@ -59,6 +60,9 @@ if (enabled(checks.matchingDependencyVersions)) {
|
|
|
59
60
|
if (enabled(checks.exactDependencyVersions)) {
|
|
60
61
|
results.push(exactDependencyVersions(projects, checks.exactDependencyVersions ?? {}))
|
|
61
62
|
}
|
|
63
|
+
if (enabled(checks.pinnedDependencyVersions)) {
|
|
64
|
+
results.push(await pinnedDependencyVersions(projects, checks.pinnedDependencyVersions ?? {}))
|
|
65
|
+
}
|
|
62
66
|
if (enabled(checks.requiredScripts)) {
|
|
63
67
|
results.push(requiredScripts(projects, checks.requiredScripts ?? {}))
|
|
64
68
|
}
|
|
@@ -84,14 +88,35 @@ if (enabled(checks.ciWorkflow)) {
|
|
|
84
88
|
results.push(await ciWorkflow(projects, checks.ciWorkflow ?? {}))
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
const findingsByProjectByCheck = new Map<string, Map<string, string[]>>()
|
|
87
92
|
for (const result of results) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
for (const finding of result.findings) {
|
|
94
|
+
const byCheck = findingsByProjectByCheck.get(finding.project) ?? new Map<string, string[]>()
|
|
95
|
+
const list = byCheck.get(result.name) ?? []
|
|
96
|
+
list.push(finding.message)
|
|
97
|
+
byCheck.set(result.name, list)
|
|
98
|
+
findingsByProjectByCheck.set(finding.project, byCheck)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let failedProjects = 0
|
|
103
|
+
for (const project of projects) {
|
|
104
|
+
const byCheck = findingsByProjectByCheck.get(project.name) ?? new Map<string, string[]>()
|
|
105
|
+
const projectFailed = byCheck.size > 0
|
|
106
|
+
if (projectFailed) failedProjects += 1
|
|
107
|
+
console.log(`${projectFailed ? '[FAIL]' : '[PASS]'} ${project.name}`)
|
|
108
|
+
for (const result of results) {
|
|
109
|
+
const messages = byCheck.get(result.name) ?? []
|
|
110
|
+
if (messages.length === 0) {
|
|
111
|
+
console.log(` [PASS] ${result.name}`)
|
|
112
|
+
} else {
|
|
113
|
+
console.log(` [FAIL] ${result.name}`)
|
|
114
|
+
for (const message of messages) {
|
|
115
|
+
console.log(` ${message}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
92
118
|
}
|
|
93
119
|
}
|
|
94
120
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
process.exit(failed === 0 ? 0 : 1)
|
|
121
|
+
console.log(`\n${projects.length - failedProjects}/${projects.length} projects passed`)
|
|
122
|
+
process.exit(failedProjects === 0 ? 0 : 1)
|
package/src/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type WardenConfig = {
|
|
|
4
4
|
configFilesPresent?: { enabled?: boolean; files?: string[] }
|
|
5
5
|
matchingDependencyVersions?: { enabled?: boolean; packages?: string[] }
|
|
6
6
|
exactDependencyVersions?: { enabled?: boolean; packages?: Record<string, string> }
|
|
7
|
+
pinnedDependencyVersions?: { enabled?: boolean; packages?: Record<string, string> }
|
|
7
8
|
requiredScripts?: { enabled?: boolean; scripts?: Record<string, string> }
|
|
8
9
|
typecheckScript?: { enabled?: boolean; modes?: Record<string, 'auto' | 'bun-filter-all'> }
|
|
9
10
|
testScriptConsistency?: {
|
|
@@ -40,8 +41,12 @@ export type Project = {
|
|
|
40
41
|
packageJson: PackageJson | null
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
export type Finding = {
|
|
45
|
+
project: string
|
|
46
|
+
message: string
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
export type CheckResult = {
|
|
44
50
|
name: string
|
|
45
|
-
|
|
46
|
-
messages: string[]
|
|
51
|
+
findings: Finding[]
|
|
47
52
|
}
|