@verekia/warden 0.0.3 → 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.3",
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,13 +36,21 @@
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
49
  "typecheckScript": {
41
50
  "modes": {
42
- "gputex": "bun-filter-all",
43
- "nanothree": "bun-filter-all"
51
+ "svg-to-tsl": "bun-filter-all",
52
+ "manalab": "bun-filter-all",
53
+ "voidcore": "bun-filter-all"
44
54
  }
45
55
  }
46
56
  }
@@ -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,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 DEFAULT_FILES = ['.oxfmtrc.json', '.oxlintrc.json']
4
4
 
@@ -6,19 +6,17 @@ export const configFilesPresent = async (
6
6
  projects: Project[],
7
7
  options: NonNullable<NonNullable<WardenConfig['checks']>['configFilesPresent']>,
8
8
  ): Promise<CheckResult> => {
9
- const messages: string[] = []
10
- let passed = true
9
+ const findings: Finding[] = []
11
10
 
12
11
  const files = options.files ?? DEFAULT_FILES
13
12
  for (const project of projects) {
14
13
  for (const file of files) {
15
14
  const exists = await Bun.file(`${project.path}/${file}`).exists()
16
15
  if (!exists) {
17
- passed = false
18
- messages.push(`${project.name}: missing ${file}`)
16
+ findings.push({ project: project.name, message: `missing ${file}` })
19
17
  }
20
18
  }
21
19
  }
22
20
 
23
- return { name: 'configFilesPresent', passed, messages }
21
+ return { name: 'configFilesPresent', findings }
24
22
  }
@@ -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 findVersion = (pkg: PackageJson, name: string): string | undefined =>
4
4
  pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]
@@ -14,40 +14,36 @@ export const exactDependencyVersions = (
14
14
  projects: Project[],
15
15
  options: NonNullable<NonNullable<WardenConfig['checks']>['exactDependencyVersions']>,
16
16
  ): CheckResult => {
17
- const messages: string[] = []
18
- let passed = true
17
+ const findings: Finding[] = []
19
18
 
20
19
  const packages = Object.entries(options.packages ?? DEFAULT_PACKAGES)
21
20
  if (packages.length === 0) {
22
- return { name: 'exactDependencyVersions', passed, messages }
21
+ return { name: 'exactDependencyVersions', findings }
23
22
  }
24
23
 
25
24
  for (const project of projects) {
26
25
  if (!project.packageJson) {
27
- passed = false
28
- messages.push(`${project.name}: no package.json`)
26
+ findings.push({ project: project.name, message: 'no package.json' })
29
27
  continue
30
28
  }
31
29
  for (const [name, expected] of packages) {
32
30
  const declared = findVersion(project.packageJson, name)
33
31
  if (declared === undefined) {
34
- passed = false
35
- messages.push(`${project.name}: ${name} not declared (expected ${expected})`)
32
+ findings.push({ project: project.name, message: `${name} not declared (expected ${expected})` })
36
33
  continue
37
34
  }
38
35
  if (!EXACT_SEMVER.test(declared)) {
39
- passed = false
40
- messages.push(
41
- `${project.name}: ${name} ${JSON.stringify(declared)} is not an exact version — use "${expected}"`,
42
- )
36
+ findings.push({
37
+ project: project.name,
38
+ message: `${name} ${JSON.stringify(declared)} is not an exact version — use "${expected}"`,
39
+ })
43
40
  continue
44
41
  }
45
42
  if (declared !== expected) {
46
- passed = false
47
- 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}` })
48
44
  }
49
45
  }
50
46
  }
51
47
 
52
- return { name: 'exactDependencyVersions', passed, messages }
48
+ return { name: 'exactDependencyVersions', findings }
53
49
  }
@@ -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 findVersion = (pkg: PackageJson, name: string): string | undefined =>
4
4
  pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? pkg.peerDependencies?.[name]
@@ -9,8 +9,7 @@ export const matchingDependencyVersions = (
9
9
  projects: Project[],
10
10
  options: NonNullable<NonNullable<WardenConfig['checks']>['matchingDependencyVersions']>,
11
11
  ): CheckResult => {
12
- const messages: string[] = []
13
- let passed = true
12
+ const findings: Finding[] = []
14
13
 
15
14
  for (const packageName of options.packages ?? DEFAULT_PACKAGES) {
16
15
  const versionToProjects = new Map<string, string[]>()
@@ -27,19 +26,26 @@ export const matchingDependencyVersions = (
27
26
  versionToProjects.set(version, existing)
28
27
  }
29
28
 
30
- if (missing.length > 0) {
31
- passed = false
32
- messages.push(`${packageName}: not declared in ${missing.join(', ')}`)
29
+ for (const project of missing) {
30
+ findings.push({ project, message: `${packageName}: not declared` })
33
31
  }
34
32
 
35
33
  if (versionToProjects.size > 1) {
36
- passed = false
37
- const detail = [...versionToProjects.entries()]
38
- .map(([version, names]) => `${version} (${names.join(', ')})`)
39
- .join(' vs ')
40
- 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
+ }
41
47
  }
42
48
  }
43
49
 
44
- return { name: 'matchingDependencyVersions', passed, messages }
50
+ return { name: 'matchingDependencyVersions', findings }
45
51
  }
@@ -1,7 +1,7 @@
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
@@ -77,8 +77,7 @@ export const nextConfig = async (
77
77
  projects: Project[],
78
78
  options: NonNullable<NonNullable<WardenConfig['checks']>['nextConfig']>,
79
79
  ): Promise<CheckResult> => {
80
- const messages: string[] = []
81
- let passed = true
80
+ const findings: Finding[] = []
82
81
 
83
82
  for (const project of projects) {
84
83
  const pkgs = await collectPackageJsons(project)
@@ -101,27 +100,26 @@ export const nextConfig = async (
101
100
  }
102
101
 
103
102
  if (configRel === null || configAbs === null) {
104
- passed = false
105
- messages.push(
106
- `${project.name}/${relPath}: declares "next" but no config file found at ${dirLabel || './'} (expected one of ${CONFIG_VARIANTS.join(', ')})`,
107
- )
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
+ })
108
107
  } else {
109
108
  let cfg: Record<string, unknown> | null = null
110
109
  try {
111
110
  cfg = await loadConfigObject(configAbs)
112
111
  } catch (err) {
113
- passed = false
114
- 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}` })
115
113
  }
116
114
 
117
115
  if (cfg) {
118
116
  for (const [key, expected] of Object.entries(options.options ?? DEFAULT_OPTIONS)) {
119
117
  if (!equal(cfg[key], expected)) {
120
- passed = false
121
118
  const actual = key in cfg ? JSON.stringify(cfg[key]) : 'missing'
122
- messages.push(
123
- `${project.name}/${configRel}: "${key}" is ${actual} — expected ${JSON.stringify(expected)}`,
124
- )
119
+ findings.push({
120
+ project: project.name,
121
+ message: `${configRel}: "${key}" is ${actual} — expected ${JSON.stringify(expected)}`,
122
+ })
125
123
  }
126
124
  }
127
125
  }
@@ -131,14 +129,14 @@ export const nextConfig = async (
131
129
  const declared = pkg.devDependencies?.[name]
132
130
  if (declared !== expected) {
133
131
  const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
134
- passed = false
135
- messages.push(
136
- `${project.name}/${relPath}: declares "next" but devDependencies["${name}"] is ${actual} — expected exact "${expected}" (must be declared in this package.json)`,
137
- )
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
+ })
138
136
  }
139
137
  }
140
138
  }
141
139
  }
142
140
 
143
- return { name: 'nextConfig', passed, messages }
141
+ return { name: 'nextConfig', findings }
144
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,7 +1,7 @@
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/
@@ -46,8 +46,7 @@ export const portlessNextDev = async (
46
46
  projects: Project[],
47
47
  options: NonNullable<NonNullable<WardenConfig['checks']>['portlessNextDev']>,
48
48
  ): Promise<CheckResult> => {
49
- const messages: string[] = []
50
- let passed = true
49
+ const findings: Finding[] = []
51
50
 
52
51
  for (const project of projects) {
53
52
  const pkgs = await collectPackageJsons(project)
@@ -60,10 +59,10 @@ export const portlessNextDev = async (
60
59
  if (!NEXT_DEV_RE.test(value)) continue
61
60
  usesNextDev = true
62
61
  if (!PORTLESS_WRAP_RE.test(value)) {
63
- passed = false
64
- messages.push(
65
- `${project.name}/${relPath}: script "${name}" is ${JSON.stringify(value)} — must be wrapped as "portless <app-name> next dev"`,
66
- )
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
+ })
67
66
  }
68
67
  }
69
68
 
@@ -72,14 +71,14 @@ export const portlessNextDev = async (
72
71
  const declared = pkg.devDependencies?.portless
73
72
  if (declared !== expectedVersion) {
74
73
  const actual = declared === undefined ? 'missing' : JSON.stringify(declared)
75
- passed = false
76
- messages.push(
77
- `${project.name}/${relPath}: uses "next dev" but devDependencies.portless is ${actual} — expected exact "${expectedVersion}" (must be declared in this package.json)`,
78
- )
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
+ })
79
78
  }
80
79
  }
81
80
  }
82
81
  }
83
82
 
84
- return { name: 'portlessNextDev', passed, messages }
83
+ return { name: 'portlessNextDev', findings }
85
84
  }
@@ -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 DEFAULT_SCRIPTS: Record<string, string> = {
4
4
  format: 'oxfmt .',
@@ -11,34 +11,34 @@ export const requiredScripts = (
11
11
  projects: Project[],
12
12
  options: NonNullable<NonNullable<WardenConfig['checks']>['requiredScripts']>,
13
13
  ): CheckResult => {
14
- const messages: string[] = []
15
- let passed = true
14
+ const findings: Finding[] = []
16
15
 
17
16
  const required = Object.entries(options.scripts ?? DEFAULT_SCRIPTS)
18
17
  if (required.length === 0) {
19
- return { name: 'requiredScripts', passed, messages }
18
+ return { name: 'requiredScripts', findings }
20
19
  }
21
20
 
22
21
  for (const project of projects) {
23
22
  const scripts = project.packageJson?.scripts
24
23
  if (!scripts) {
25
- passed = false
26
- messages.push(`${project.name}: no "scripts" in package.json`)
24
+ findings.push({ project: project.name, message: 'no "scripts" in package.json' })
27
25
  continue
28
26
  }
29
27
  for (const [name, expected] of required) {
30
28
  const actual = scripts[name]
31
29
  if (actual === undefined) {
32
- passed = false
33
- 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
+ })
34
34
  } else if (actual !== expected) {
35
- passed = false
36
- messages.push(
37
- `${project.name}: script "${name}" is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
38
- )
35
+ findings.push({
36
+ project: project.name,
37
+ message: `script "${name}" is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
38
+ })
39
39
  }
40
40
  }
41
41
  }
42
42
 
43
- return { name: 'requiredScripts', passed, messages }
43
+ return { name: 'requiredScripts', findings }
44
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)
@@ -21,8 +21,7 @@ export const testScriptConsistency = async (
21
21
  projects: Project[],
22
22
  options: NonNullable<NonNullable<WardenConfig['checks']>['testScriptConsistency']>,
23
23
  ): Promise<CheckResult> => {
24
- const messages: string[] = []
25
- let passed = true
24
+ const findings: Finding[] = []
26
25
 
27
26
  const testFilePattern = options.testFilePattern ?? DEFAULT_TEST_FILE_PATTERN
28
27
  const testScript = options.testScript ?? DEFAULT_TEST_SCRIPT
@@ -35,26 +34,26 @@ export const testScriptConsistency = async (
35
34
 
36
35
  if (hasTests) {
37
36
  if (scripts.test !== testScript) {
38
- passed = false
39
- messages.push(
40
- `${project.name}: has test files but "test" script is ${JSON.stringify(scripts.test)}, expected ${JSON.stringify(testScript)}`,
41
- )
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
+ })
42
41
  }
43
42
  if (scripts.all !== allWithTests) {
44
- passed = false
45
- messages.push(
46
- `${project.name}: "all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(allWithTests)} (project has tests)`,
47
- )
43
+ findings.push({
44
+ project: project.name,
45
+ message: `"all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(allWithTests)} (project has tests)`,
46
+ })
48
47
  }
49
48
  } else {
50
49
  if (scripts.all !== allWithoutTests) {
51
- passed = false
52
- messages.push(
53
- `${project.name}: "all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(allWithoutTests)} (no tests detected)`,
54
- )
50
+ findings.push({
51
+ project: project.name,
52
+ message: `"all" is ${JSON.stringify(scripts.all)}, expected ${JSON.stringify(allWithoutTests)} (no tests detected)`,
53
+ })
55
54
  }
56
55
  }
57
56
  }
58
57
 
59
- return { name: 'testScriptConsistency', passed, messages }
58
+ return { name: 'testScriptConsistency', findings }
60
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
  }