@soulbatical/tetra-dev-toolkit 1.3.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tetra-setup.js +555 -2
- package/lib/checks/health/bundle-size.js +156 -0
- package/lib/checks/health/conventional-commits.js +164 -0
- package/lib/checks/health/coverage-thresholds.js +199 -0
- package/lib/checks/health/dependency-automation.js +116 -0
- package/lib/checks/health/dependency-cruiser.js +113 -0
- package/lib/checks/health/eslint-security.js +172 -0
- package/lib/checks/health/index.js +12 -1
- package/lib/checks/health/knip.js +135 -0
- package/lib/checks/health/license-audit.js +133 -0
- package/lib/checks/health/prettier.js +162 -0
- package/lib/checks/health/sast.js +166 -0
- package/lib/checks/health/scanner.js +24 -2
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/health/typescript-strict.js +143 -0
- package/package.json +5 -3
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Bundle Size Tracking
|
|
3
|
+
*
|
|
4
|
+
* Checks for bundle size monitoring and regression prevention.
|
|
5
|
+
* Score: up to 2 points:
|
|
6
|
+
* +1 for bundle analysis tool (bundlewatch, size-limit, @next/bundle-analyzer, webpack-bundle-analyzer)
|
|
7
|
+
* +1 for budget config or CI integration (lighthouse budget counts)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { createCheck } from './types.js'
|
|
13
|
+
|
|
14
|
+
export async function check(projectPath) {
|
|
15
|
+
const result = createCheck('bundle-size', 2, {
|
|
16
|
+
tool: null,
|
|
17
|
+
toolInstalled: false,
|
|
18
|
+
hasConfig: false,
|
|
19
|
+
configPath: null,
|
|
20
|
+
hasBudget: false,
|
|
21
|
+
hasCiIntegration: false,
|
|
22
|
+
message: ''
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Bundle analysis tools
|
|
26
|
+
const bundleTools = {
|
|
27
|
+
'bundlewatch': 'bundlewatch',
|
|
28
|
+
'size-limit': 'size-limit',
|
|
29
|
+
'@size-limit/preset-small-lib': 'size-limit',
|
|
30
|
+
'@size-limit/preset-app': 'size-limit',
|
|
31
|
+
'@next/bundle-analyzer': 'next-bundle-analyzer',
|
|
32
|
+
'webpack-bundle-analyzer': 'webpack-bundle-analyzer',
|
|
33
|
+
'rollup-plugin-visualizer': 'rollup-visualizer',
|
|
34
|
+
'source-map-explorer': 'source-map-explorer',
|
|
35
|
+
'vite-bundle-visualizer': 'vite-bundle-visualizer'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check package.json files
|
|
39
|
+
const packageLocations = [
|
|
40
|
+
'package.json',
|
|
41
|
+
'backend/package.json',
|
|
42
|
+
'frontend/package.json',
|
|
43
|
+
'frontend-user/package.json'
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
for (const loc of packageLocations) {
|
|
47
|
+
const pkgPath = join(projectPath, loc)
|
|
48
|
+
if (!existsSync(pkgPath)) continue
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
52
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
53
|
+
|
|
54
|
+
// Check for bundle tools
|
|
55
|
+
for (const [dep, tool] of Object.entries(bundleTools)) {
|
|
56
|
+
if (allDeps[dep]) {
|
|
57
|
+
result.details.toolInstalled = true
|
|
58
|
+
result.details.tool = tool
|
|
59
|
+
result.score += 1
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for size-limit config in package.json
|
|
65
|
+
if (pkg['size-limit']) {
|
|
66
|
+
result.details.hasConfig = true
|
|
67
|
+
result.details.hasBudget = true
|
|
68
|
+
result.details.configPath = `${loc} (size-limit key)`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check for bundle analysis scripts
|
|
72
|
+
const scripts = pkg.scripts || {}
|
|
73
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
74
|
+
if (typeof cmd !== 'string') continue
|
|
75
|
+
if (name.includes('analyze') || name.includes('bundle') ||
|
|
76
|
+
cmd.includes('ANALYZE=true') || cmd.includes('bundle-analyzer') ||
|
|
77
|
+
cmd.includes('size-limit') || cmd.includes('bundlewatch')) {
|
|
78
|
+
result.details.hasConfig = true
|
|
79
|
+
result.details.configPath = result.details.configPath || `${loc} → ${name}`
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch { /* ignore */ }
|
|
84
|
+
|
|
85
|
+
if (result.details.toolInstalled) break
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check for bundlewatch config
|
|
89
|
+
const bundlewatchConfigs = [
|
|
90
|
+
'.bundlewatch.config.json',
|
|
91
|
+
'bundlewatch.config.js'
|
|
92
|
+
]
|
|
93
|
+
for (const config of bundlewatchConfigs) {
|
|
94
|
+
if (existsSync(join(projectPath, config))) {
|
|
95
|
+
result.details.hasConfig = true
|
|
96
|
+
result.details.hasBudget = true
|
|
97
|
+
result.details.configPath = config
|
|
98
|
+
if (!result.details.tool) result.details.tool = 'bundlewatch'
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check for size-limit config
|
|
104
|
+
if (existsSync(join(projectPath, '.size-limit.json')) ||
|
|
105
|
+
existsSync(join(projectPath, '.size-limit.js'))) {
|
|
106
|
+
result.details.hasConfig = true
|
|
107
|
+
result.details.hasBudget = true
|
|
108
|
+
if (!result.details.tool) result.details.tool = 'size-limit'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for Lighthouse budget (counts as budget)
|
|
112
|
+
if (existsSync(join(projectPath, 'lighthouse-budget.json')) ||
|
|
113
|
+
existsSync(join(projectPath, 'frontend-user/lighthouse-budget.json'))) {
|
|
114
|
+
result.details.hasBudget = true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check CI for bundle size checks
|
|
118
|
+
const workflowDir = join(projectPath, '.github/workflows')
|
|
119
|
+
if (existsSync(workflowDir)) {
|
|
120
|
+
try {
|
|
121
|
+
const files = readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
try {
|
|
124
|
+
const content = readFileSync(join(workflowDir, file), 'utf-8')
|
|
125
|
+
if (content.includes('bundlewatch') || content.includes('size-limit') ||
|
|
126
|
+
content.includes('bundle-analyzer') || content.includes('lighthouse')) {
|
|
127
|
+
result.details.hasCiIntegration = true
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
} catch { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
} catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Budget or CI integration counts toward second point
|
|
136
|
+
if (result.details.hasBudget || result.details.hasCiIntegration) {
|
|
137
|
+
result.score += 1
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
141
|
+
|
|
142
|
+
// Determine status
|
|
143
|
+
if (result.score === 0) {
|
|
144
|
+
result.status = 'warning'
|
|
145
|
+
result.details.message = 'No bundle size tracking — regressions go undetected'
|
|
146
|
+
} else if (result.score < 2) {
|
|
147
|
+
result.status = 'warning'
|
|
148
|
+
result.details.message = result.details.toolInstalled
|
|
149
|
+
? `${result.details.tool} installed but no budget or CI enforcement`
|
|
150
|
+
: 'Budget defined but no analysis tool installed'
|
|
151
|
+
} else {
|
|
152
|
+
result.details.message = `Bundle size tracked via ${result.details.tool || 'lighthouse budgets'}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Conventional Commits
|
|
3
|
+
*
|
|
4
|
+
* Checks for commit message convention enforcement and changelog generation.
|
|
5
|
+
* Score: up to 2 points:
|
|
6
|
+
* +1 for commitlint config OR standard-version/semantic-release config
|
|
7
|
+
* +1 for commit-msg hook OR release script
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { createCheck } from './types.js'
|
|
13
|
+
|
|
14
|
+
export async function check(projectPath) {
|
|
15
|
+
const result = createCheck('conventional-commits', 2, {
|
|
16
|
+
hasCommitlint: false,
|
|
17
|
+
commitlintConfig: null,
|
|
18
|
+
hasVersionTool: false,
|
|
19
|
+
versionTool: null,
|
|
20
|
+
hasCommitMsgHook: false,
|
|
21
|
+
hasReleaseScript: false,
|
|
22
|
+
releaseScript: null,
|
|
23
|
+
hasChangelog: false,
|
|
24
|
+
message: ''
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Check for commitlint config
|
|
28
|
+
const commitlintConfigs = [
|
|
29
|
+
'commitlint.config.js',
|
|
30
|
+
'commitlint.config.cjs',
|
|
31
|
+
'commitlint.config.mjs',
|
|
32
|
+
'commitlint.config.ts',
|
|
33
|
+
'.commitlintrc',
|
|
34
|
+
'.commitlintrc.json',
|
|
35
|
+
'.commitlintrc.yml',
|
|
36
|
+
'.commitlintrc.js',
|
|
37
|
+
'.commitlintrc.cjs'
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for (const config of commitlintConfigs) {
|
|
41
|
+
if (existsSync(join(projectPath, config))) {
|
|
42
|
+
result.details.hasCommitlint = true
|
|
43
|
+
result.details.commitlintConfig = config
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check package.json for commitlint key
|
|
49
|
+
const pkgPath = join(projectPath, 'package.json')
|
|
50
|
+
let rootPkg = null
|
|
51
|
+
if (existsSync(pkgPath)) {
|
|
52
|
+
try {
|
|
53
|
+
rootPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
54
|
+
if (rootPkg.commitlint) {
|
|
55
|
+
result.details.hasCommitlint = true
|
|
56
|
+
result.details.commitlintConfig = 'package.json (commitlint key)'
|
|
57
|
+
}
|
|
58
|
+
} catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check for versioning tools
|
|
62
|
+
const versionConfigs = [
|
|
63
|
+
{ file: '.versionrc', tool: 'standard-version' },
|
|
64
|
+
{ file: '.versionrc.json', tool: 'standard-version' },
|
|
65
|
+
{ file: '.versionrc.js', tool: 'standard-version' },
|
|
66
|
+
{ file: 'backend/.versionrc.json', tool: 'standard-version' },
|
|
67
|
+
{ file: '.releaserc', tool: 'semantic-release' },
|
|
68
|
+
{ file: '.releaserc.json', tool: 'semantic-release' },
|
|
69
|
+
{ file: '.releaserc.js', tool: 'semantic-release' },
|
|
70
|
+
{ file: 'release.config.js', tool: 'semantic-release' },
|
|
71
|
+
{ file: '.changeset/config.json', tool: 'changesets' }
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
for (const { file, tool } of versionConfigs) {
|
|
75
|
+
if (existsSync(join(projectPath, file))) {
|
|
76
|
+
result.details.hasVersionTool = true
|
|
77
|
+
result.details.versionTool = tool
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check dependencies for version tools
|
|
83
|
+
const packageLocations = ['package.json', 'backend/package.json']
|
|
84
|
+
for (const loc of packageLocations) {
|
|
85
|
+
const path = join(projectPath, loc)
|
|
86
|
+
if (!existsSync(path)) continue
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8'))
|
|
90
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
91
|
+
|
|
92
|
+
if (allDeps['standard-version'] && !result.details.hasVersionTool) {
|
|
93
|
+
result.details.hasVersionTool = true
|
|
94
|
+
result.details.versionTool = 'standard-version'
|
|
95
|
+
}
|
|
96
|
+
if (allDeps['semantic-release'] && !result.details.hasVersionTool) {
|
|
97
|
+
result.details.hasVersionTool = true
|
|
98
|
+
result.details.versionTool = 'semantic-release'
|
|
99
|
+
}
|
|
100
|
+
if (allDeps['@changesets/cli'] && !result.details.hasVersionTool) {
|
|
101
|
+
result.details.hasVersionTool = true
|
|
102
|
+
result.details.versionTool = 'changesets'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check for release scripts
|
|
106
|
+
const scripts = pkg.scripts || {}
|
|
107
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
108
|
+
if (typeof cmd !== 'string') continue
|
|
109
|
+
if (name.includes('release') || cmd.includes('standard-version') ||
|
|
110
|
+
cmd.includes('semantic-release') || cmd.includes('changeset')) {
|
|
111
|
+
result.details.hasReleaseScript = true
|
|
112
|
+
result.details.releaseScript = `${loc} → ${name}`
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for commit-msg hook
|
|
120
|
+
const commitMsgHooks = [
|
|
121
|
+
'.husky/commit-msg',
|
|
122
|
+
'backend/.husky/commit-msg'
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
for (const hook of commitMsgHooks) {
|
|
126
|
+
if (existsSync(join(projectPath, hook))) {
|
|
127
|
+
result.details.hasCommitMsgHook = true
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check for CHANGELOG
|
|
133
|
+
if (existsSync(join(projectPath, 'CHANGELOG.md')) ||
|
|
134
|
+
existsSync(join(projectPath, 'backend/CHANGELOG.md'))) {
|
|
135
|
+
result.details.hasChangelog = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Scoring
|
|
139
|
+
if (result.details.hasCommitlint || result.details.hasVersionTool) {
|
|
140
|
+
result.score += 1
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (result.details.hasCommitMsgHook || result.details.hasReleaseScript) {
|
|
144
|
+
result.score += 1
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
148
|
+
|
|
149
|
+
// Determine status
|
|
150
|
+
if (result.score === 0) {
|
|
151
|
+
result.status = 'warning'
|
|
152
|
+
result.details.message = 'No commit convention or release management configured'
|
|
153
|
+
} else if (result.score < 2) {
|
|
154
|
+
result.status = 'warning'
|
|
155
|
+
const tool = result.details.hasCommitlint ? 'commitlint' :
|
|
156
|
+
result.details.versionTool || 'commit tool'
|
|
157
|
+
result.details.message = `${tool} configured but no enforcement hook or release script`
|
|
158
|
+
} else {
|
|
159
|
+
const tool = result.details.versionTool || 'commitlint'
|
|
160
|
+
result.details.message = `Commit conventions enforced via ${tool}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Test Coverage Thresholds
|
|
3
|
+
*
|
|
4
|
+
* Checks that test coverage thresholds are configured and enforced.
|
|
5
|
+
* Score: up to 3 points:
|
|
6
|
+
* +1 for coverage config in test framework (jest/vitest)
|
|
7
|
+
* +1 for threshold values defined (statements/branches/lines)
|
|
8
|
+
* +1 for coverage script in package.json or CI enforcement
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { createCheck } from './types.js'
|
|
14
|
+
|
|
15
|
+
export async function check(projectPath) {
|
|
16
|
+
const result = createCheck('coverage-thresholds', 3, {
|
|
17
|
+
framework: null,
|
|
18
|
+
hasCoverageConfig: false,
|
|
19
|
+
hasThresholds: false,
|
|
20
|
+
thresholds: null,
|
|
21
|
+
hasCoverageScript: false,
|
|
22
|
+
coverageScript: null,
|
|
23
|
+
hasCiCoverage: false,
|
|
24
|
+
message: ''
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Check Jest configs
|
|
28
|
+
const jestConfigs = [
|
|
29
|
+
'jest.config.js',
|
|
30
|
+
'jest.config.ts',
|
|
31
|
+
'jest.config.cjs',
|
|
32
|
+
'jest.config.mjs',
|
|
33
|
+
'backend/jest.config.js',
|
|
34
|
+
'backend/jest.config.ts'
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
for (const config of jestConfigs) {
|
|
38
|
+
const configPath = join(projectPath, config)
|
|
39
|
+
if (!existsSync(configPath)) continue
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
43
|
+
result.details.framework = 'jest'
|
|
44
|
+
|
|
45
|
+
// Check for coverage configuration
|
|
46
|
+
if (content.includes('collectCoverage') || content.includes('coverageDirectory') ||
|
|
47
|
+
content.includes('coverageReporters') || content.includes('collectCoverageFrom')) {
|
|
48
|
+
result.details.hasCoverageConfig = true
|
|
49
|
+
result.score += 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check for thresholds
|
|
53
|
+
if (content.includes('coverageThreshold')) {
|
|
54
|
+
result.details.hasThresholds = true
|
|
55
|
+
result.score += 1
|
|
56
|
+
|
|
57
|
+
// Try to extract threshold values
|
|
58
|
+
const thresholdMatch = content.match(/coverageThreshold\s*:\s*\{[\s\S]*?global\s*:\s*\{([^}]+)\}/)
|
|
59
|
+
if (thresholdMatch) {
|
|
60
|
+
result.details.thresholds = thresholdMatch[1].trim()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* ignore */ }
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check Vitest configs
|
|
68
|
+
if (!result.details.framework) {
|
|
69
|
+
const vitestConfigs = [
|
|
70
|
+
'vitest.config.ts',
|
|
71
|
+
'vitest.config.js',
|
|
72
|
+
'vitest.config.mjs',
|
|
73
|
+
'vite.config.ts',
|
|
74
|
+
'vite.config.js',
|
|
75
|
+
'backend/vitest.config.ts',
|
|
76
|
+
'backend/vite.config.ts'
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for (const config of vitestConfigs) {
|
|
80
|
+
const configPath = join(projectPath, config)
|
|
81
|
+
if (!existsSync(configPath)) continue
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
85
|
+
|
|
86
|
+
// Only count as vitest if it has test config
|
|
87
|
+
if (!content.includes('test')) continue
|
|
88
|
+
|
|
89
|
+
result.details.framework = 'vitest'
|
|
90
|
+
|
|
91
|
+
if (content.includes('coverage')) {
|
|
92
|
+
result.details.hasCoverageConfig = true
|
|
93
|
+
result.score += 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (content.includes('thresholds') || content.includes('threshold')) {
|
|
97
|
+
result.details.hasThresholds = true
|
|
98
|
+
result.score += 1
|
|
99
|
+
}
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Also check package.json for jest coverage config
|
|
106
|
+
if (!result.details.hasCoverageConfig) {
|
|
107
|
+
const pkgPath = join(projectPath, 'package.json')
|
|
108
|
+
if (existsSync(pkgPath)) {
|
|
109
|
+
try {
|
|
110
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
111
|
+
if (pkg.jest) {
|
|
112
|
+
result.details.framework = result.details.framework || 'jest'
|
|
113
|
+
if (pkg.jest.collectCoverage || pkg.jest.coverageReporters) {
|
|
114
|
+
result.details.hasCoverageConfig = true
|
|
115
|
+
result.score += 1
|
|
116
|
+
}
|
|
117
|
+
if (pkg.jest.coverageThreshold) {
|
|
118
|
+
result.details.hasThresholds = true
|
|
119
|
+
result.score += 1
|
|
120
|
+
const global = pkg.jest.coverageThreshold.global
|
|
121
|
+
if (global) {
|
|
122
|
+
result.details.thresholds = global
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch { /* ignore */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check for coverage script
|
|
131
|
+
const packageLocations = [
|
|
132
|
+
'package.json',
|
|
133
|
+
'backend/package.json',
|
|
134
|
+
'frontend/package.json'
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
for (const loc of packageLocations) {
|
|
138
|
+
const pkgPath = join(projectPath, loc)
|
|
139
|
+
if (!existsSync(pkgPath)) continue
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
143
|
+
const scripts = pkg.scripts || {}
|
|
144
|
+
|
|
145
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
146
|
+
if (typeof cmd !== 'string') continue
|
|
147
|
+
if (name.includes('coverage') || cmd.includes('--coverage') ||
|
|
148
|
+
cmd.includes('--coverageThreshold')) {
|
|
149
|
+
result.details.hasCoverageScript = true
|
|
150
|
+
result.details.coverageScript = `${loc} → ${name}`
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (result.details.hasCoverageScript) break
|
|
155
|
+
} catch { /* ignore */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check CI workflows for coverage enforcement
|
|
159
|
+
const ciFiles = [
|
|
160
|
+
'.github/workflows/quality.yml',
|
|
161
|
+
'.github/workflows/test.yml',
|
|
162
|
+
'.github/workflows/ci.yml',
|
|
163
|
+
'.github/workflows/admin-mode-tests.yml'
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
for (const ci of ciFiles) {
|
|
167
|
+
const ciPath = join(projectPath, ci)
|
|
168
|
+
if (!existsSync(ciPath)) continue
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const content = readFileSync(ciPath, 'utf-8')
|
|
172
|
+
if (content.includes('coverage') || content.includes('--coverageThreshold')) {
|
|
173
|
+
result.details.hasCiCoverage = true
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
} catch { /* ignore */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (result.details.hasCoverageScript || result.details.hasCiCoverage) {
|
|
180
|
+
result.score += 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
184
|
+
|
|
185
|
+
// Determine status
|
|
186
|
+
if (result.score === 0) {
|
|
187
|
+
result.status = result.details.framework ? 'warning' : 'error'
|
|
188
|
+
result.details.message = result.details.framework
|
|
189
|
+
? `${result.details.framework} detected but no coverage configuration`
|
|
190
|
+
: 'No test framework or coverage configuration detected'
|
|
191
|
+
} else if (result.score < 2) {
|
|
192
|
+
result.status = 'warning'
|
|
193
|
+
result.details.message = 'Coverage partially configured — add thresholds for enforcement'
|
|
194
|
+
} else {
|
|
195
|
+
result.details.message = `Coverage well-configured (${result.details.framework})`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Dependency Update Automation
|
|
3
|
+
*
|
|
4
|
+
* Checks for automated dependency updates via Renovate or Dependabot.
|
|
5
|
+
* Score: up to 2 points:
|
|
6
|
+
* +1 for Renovate or Dependabot config exists
|
|
7
|
+
* +1 for automerge or schedule configured (indicates active maintenance)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
|
+
import { join } from 'path'
|
|
12
|
+
import { createCheck } from './types.js'
|
|
13
|
+
|
|
14
|
+
export async function check(projectPath) {
|
|
15
|
+
const result = createCheck('dependency-automation', 2, {
|
|
16
|
+
tool: null,
|
|
17
|
+
configFound: false,
|
|
18
|
+
configPath: null,
|
|
19
|
+
hasSchedule: false,
|
|
20
|
+
hasAutomerge: false,
|
|
21
|
+
message: ''
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Check for Renovate config
|
|
25
|
+
const renovateConfigs = [
|
|
26
|
+
'renovate.json',
|
|
27
|
+
'renovate.json5',
|
|
28
|
+
'.renovaterc',
|
|
29
|
+
'.renovaterc.json',
|
|
30
|
+
'.renovaterc.json5',
|
|
31
|
+
'.github/renovate.json',
|
|
32
|
+
'.github/renovate.json5'
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
for (const config of renovateConfigs) {
|
|
36
|
+
const configPath = join(projectPath, config)
|
|
37
|
+
if (!existsSync(configPath)) continue
|
|
38
|
+
|
|
39
|
+
result.details.tool = 'renovate'
|
|
40
|
+
result.details.configFound = true
|
|
41
|
+
result.details.configPath = config
|
|
42
|
+
result.score += 1
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
46
|
+
if (content.includes('schedule')) result.details.hasSchedule = true
|
|
47
|
+
if (content.includes('automerge')) result.details.hasAutomerge = true
|
|
48
|
+
} catch { /* ignore */ }
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Also check package.json for renovate key
|
|
53
|
+
if (!result.details.configFound) {
|
|
54
|
+
const pkgPath = join(projectPath, 'package.json')
|
|
55
|
+
if (existsSync(pkgPath)) {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
58
|
+
if (pkg.renovate) {
|
|
59
|
+
result.details.tool = 'renovate'
|
|
60
|
+
result.details.configFound = true
|
|
61
|
+
result.details.configPath = 'package.json (renovate key)'
|
|
62
|
+
result.score += 1
|
|
63
|
+
|
|
64
|
+
const content = JSON.stringify(pkg.renovate)
|
|
65
|
+
if (content.includes('schedule')) result.details.hasSchedule = true
|
|
66
|
+
if (content.includes('automerge')) result.details.hasAutomerge = true
|
|
67
|
+
}
|
|
68
|
+
} catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for Dependabot config
|
|
73
|
+
if (!result.details.configFound) {
|
|
74
|
+
const dependabotPath = join(projectPath, '.github/dependabot.yml')
|
|
75
|
+
const dependabotAlt = join(projectPath, '.github/dependabot.yaml')
|
|
76
|
+
|
|
77
|
+
const depPath = existsSync(dependabotPath) ? dependabotPath :
|
|
78
|
+
existsSync(dependabotAlt) ? dependabotAlt : null
|
|
79
|
+
|
|
80
|
+
if (depPath) {
|
|
81
|
+
result.details.tool = 'dependabot'
|
|
82
|
+
result.details.configFound = true
|
|
83
|
+
result.details.configPath = depPath.replace(projectPath + '/', '')
|
|
84
|
+
result.score += 1
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const content = readFileSync(depPath, 'utf-8')
|
|
88
|
+
if (content.includes('schedule')) result.details.hasSchedule = true
|
|
89
|
+
// Dependabot doesn't have automerge natively, but check for it
|
|
90
|
+
if (content.includes('auto-merge') || content.includes('automerge')) {
|
|
91
|
+
result.details.hasAutomerge = true
|
|
92
|
+
}
|
|
93
|
+
} catch { /* ignore */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Bonus point for active config
|
|
98
|
+
if (result.details.hasSchedule || result.details.hasAutomerge) {
|
|
99
|
+
result.score += 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
103
|
+
|
|
104
|
+
// Determine status
|
|
105
|
+
if (result.score === 0) {
|
|
106
|
+
result.status = 'warning'
|
|
107
|
+
result.details.message = 'No dependency update automation — outdated deps accumulate silently'
|
|
108
|
+
} else if (result.score < 2) {
|
|
109
|
+
result.status = 'warning'
|
|
110
|
+
result.details.message = `${result.details.tool} configured but no schedule or automerge`
|
|
111
|
+
} else {
|
|
112
|
+
result.details.message = `Dependencies auto-updated via ${result.details.tool}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result
|
|
116
|
+
}
|