@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verekia/warden",
3
- "version": "0.0.2",
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
- "nanothree": "bun-filter-all"
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 messages: string[] = []
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
- passed = false
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
- passed = false
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
- passed = false
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', passed, messages }
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 messages: string[] = []
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
- passed = false
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', passed, messages }
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 messages: string[] = []
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', passed, messages }
21
+ return { name: 'exactDependencyVersions', findings }
18
22
  }
19
23
 
20
24
  for (const project of projects) {
21
25
  if (!project.packageJson) {
22
- passed = false
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
- passed = false
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
- passed = false
35
- messages.push(
36
- `${project.name}: ${name} ${JSON.stringify(declared)} is not an exact version — use "${expected}"`,
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
- passed = false
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', passed, messages }
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 messages: string[] = []
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
- if (missing.length > 0) {
29
- passed = false
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
- passed = false
35
- const detail = [...versionToProjects.entries()]
36
- .map(([version, names]) => `${version} (${names.join(', ')})`)
37
- .join(' vs ')
38
- messages.push(`${packageName}: version mismatch ${detail}`)
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', passed, messages }
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 messages: string[] = []
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
- passed = false
96
- messages.push(
97
- `${project.name}/${relPath}: declares "next" but no config file found at ${dirLabel || './'} (expected one of ${CONFIG_VARIANTS.join(', ')})`,
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
- passed = false
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
- messages.push(
114
- `${project.name}/${configRel}: "${key}" is ${actual} — expected ${JSON.stringify(expected)}`,
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
- passed = false
126
- messages.push(
127
- `${project.name}/${relPath}: declares "next" but devDependencies["${name}"] is ${actual} — expected exact "${expected}" (must be declared in this package.json)`,
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', passed, messages }
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 messages: string[] = []
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
- passed = false
63
- messages.push(
64
- `${project.name}/${relPath}: script "${name}" is ${JSON.stringify(value)} — must be wrapped as "portless <app-name> next dev"`,
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 && options.version !== undefined) {
69
+ if (usesNextDev) {
70
+ const expectedVersion = options.version ?? DEFAULT_VERSION
70
71
  const declared = pkg.devDependencies?.portless
71
- if (declared !== options.version) {
72
+ if (declared !== expectedVersion) {
72
73
  const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
73
- passed = false
74
- messages.push(
75
- `${project.name}/${relPath}: uses "next dev" but devDependencies.portless is ${actual} — expected exact "${options.version}" (must be declared in this package.json)`,
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', passed, messages }
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 messages: string[] = []
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', passed, messages }
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
- passed = false
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
- passed = false
26
- messages.push(`${project.name}: missing script "${name}" (expected ${JSON.stringify(expected)})`)
30
+ findings.push({
31
+ project: project.name,
32
+ message: `missing script "${name}" (expected ${JSON.stringify(expected)})`,
33
+ })
27
34
  } else if (actual !== expected) {
28
- passed = false
29
- messages.push(
30
- `${project.name}: script "${name}" is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
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', passed, messages }
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 messages: string[] = []
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
- passed = false
28
- messages.push(
29
- `${project.name}: ${OXFMT_CONFIG} "${REQUIRED_KEY}" must be \`true\` (got ${JSON.stringify(oxfmt[REQUIRED_KEY])})`,
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
- passed = false
36
- messages.push(`${project.name}: depends on ${TAILWIND} but ${OXFMT_CONFIG} is missing "${REQUIRED_KEY}": true`)
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', passed, messages }
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 messages: string[] = []
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
- passed = false
34
- messages.push(
35
- `${project.name}: has test files but "test" script is ${JSON.stringify(scripts.test)}, expected ${JSON.stringify(testScript)}`,
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 (options.allWithTests !== undefined && scripts.all !== options.allWithTests) {
39
- passed = false
40
- messages.push(
41
- `${project.name}: "all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(options.allWithTests)} (project has tests)`,
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 (options.allWithoutTests !== undefined && scripts.all !== options.allWithoutTests) {
46
- passed = false
47
- messages.push(
48
- `${project.name}: "all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(options.allWithoutTests)} (no tests detected)`,
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', passed, messages }
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 messages: string[] = []
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
- passed = false
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
- passed = false
42
- messages.push(`${project.name}: missing script "typecheck" (expected ${JSON.stringify(expected)})`)
39
+ findings.push({
40
+ project: project.name,
41
+ message: `missing script "typecheck" (expected ${JSON.stringify(expected)})`,
42
+ })
43
43
  } else if (actual !== expected) {
44
- passed = false
45
- messages.push(
46
- `${project.name}: script "typecheck" is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)} (${reason})`,
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', passed, messages }
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 messages: string[] = []
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
- passed = false
23
- messages.push(
24
- `${project.name}: depends on ${packageName} but "warden" script is ${JSON.stringify(scripts.warden)}, expected "warden"`,
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
- passed = false
29
- messages.push(
30
- `${project.name}: depends on ${packageName} but "all" script ${JSON.stringify(scripts.all)} is missing "bun run warden"`,
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', passed, messages }
34
+ return { name: 'wardenScript', findings }
36
35
  }
@@ -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 tag = result.passed ? '[PASS]' : '[FAIL]'
89
- console.log(`${tag} ${result.name}`)
90
- for (const message of result.messages) {
91
- console.log(` ${message}`)
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
- const failed = results.filter(r => !r.passed).length
96
- console.log(`\n${results.length - failed}/${results.length} checks passed`)
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
- passed: boolean
46
- messages: string[]
51
+ findings: Finding[]
47
52
  }