@soulbatical/tetra-dev-toolkit 1.4.0 â 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 +94 -2
- package/lib/checks/health/bundle-size.js +156 -0
- package/lib/checks/health/dependency-automation.js +116 -0
- package/lib/checks/health/index.js +6 -1
- package/lib/checks/health/knip.js +135 -0
- package/lib/checks/health/license-audit.js +133 -0
- package/lib/checks/health/sast.js +166 -0
- package/lib/checks/health/scanner.js +12 -2
- package/lib/checks/health/types.js +1 -1
- package/package.json +1 -1
package/bin/tetra-setup.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* tetra-setup lighthouse # Setup Lighthouse CI budgets
|
|
20
20
|
* tetra-setup commitlint # Setup commitlint + commit-msg hook
|
|
21
21
|
* tetra-setup depcruiser # Setup dependency-cruiser
|
|
22
|
+
* tetra-setup knip # Setup Knip (dead code detection)
|
|
23
|
+
* tetra-setup license-audit # Setup license compliance checking
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import { program } from 'commander'
|
|
@@ -32,7 +34,7 @@ program
|
|
|
32
34
|
.name('tetra-setup')
|
|
33
35
|
.description('Setup Tetra Dev Toolkit in your project')
|
|
34
36
|
.version('1.2.0')
|
|
35
|
-
.argument('[component]', 'Component to setup: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, or all')
|
|
37
|
+
.argument('[component]', 'Component to setup: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, knip, license-audit, or all')
|
|
36
38
|
.option('-f, --force', 'Overwrite existing files')
|
|
37
39
|
.action(async (component, options) => {
|
|
38
40
|
console.log('')
|
|
@@ -73,9 +75,15 @@ program
|
|
|
73
75
|
case 'depcruiser':
|
|
74
76
|
await setupDepCruiser(options)
|
|
75
77
|
break
|
|
78
|
+
case 'knip':
|
|
79
|
+
await setupKnip(options)
|
|
80
|
+
break
|
|
81
|
+
case 'license-audit':
|
|
82
|
+
await setupLicenseAudit(options)
|
|
83
|
+
break
|
|
76
84
|
default:
|
|
77
85
|
console.log(`Unknown component: ${comp}`)
|
|
78
|
-
console.log('Available: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser')
|
|
86
|
+
console.log('Available: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, knip, license-audit')
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
|
|
@@ -719,4 +727,88 @@ module.exports = {
|
|
|
719
727
|
console.log(' đĻ Run: npm install --save-dev dependency-cruiser')
|
|
720
728
|
}
|
|
721
729
|
|
|
730
|
+
// âââ Knip (Dead Code Detection) âââââââââââââââââââââââââââââ
|
|
731
|
+
|
|
732
|
+
async function setupKnip(options) {
|
|
733
|
+
console.log('đ Setting up Knip (dead code detection)...')
|
|
734
|
+
|
|
735
|
+
const knipPath = join(projectRoot, 'knip.config.ts')
|
|
736
|
+
if (!existsSync(knipPath) || options.force) {
|
|
737
|
+
const content = `import type { KnipConfig } from 'knip'
|
|
738
|
+
|
|
739
|
+
const config: KnipConfig = {
|
|
740
|
+
entry: ['src/index.{ts,js}', 'src/main.{ts,tsx}'],
|
|
741
|
+
project: ['src/**/*.{ts,tsx,js,jsx}'],
|
|
742
|
+
ignore: [
|
|
743
|
+
'**/*.test.{ts,tsx}',
|
|
744
|
+
'**/*.spec.{ts,tsx}',
|
|
745
|
+
'**/test/**',
|
|
746
|
+
'**/tests/**'
|
|
747
|
+
],
|
|
748
|
+
ignoreDependencies: [
|
|
749
|
+
// Add dependencies that are used but not detected by Knip
|
|
750
|
+
]
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export default config
|
|
754
|
+
`
|
|
755
|
+
writeFileSync(knipPath, content)
|
|
756
|
+
console.log(' â
Created knip.config.ts')
|
|
757
|
+
} else {
|
|
758
|
+
console.log(' âī¸ knip.config.ts already exists')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Add scripts
|
|
762
|
+
const packagePath = join(projectRoot, 'package.json')
|
|
763
|
+
if (existsSync(packagePath)) {
|
|
764
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
|
765
|
+
pkg.scripts = pkg.scripts || {}
|
|
766
|
+
let changed = false
|
|
767
|
+
|
|
768
|
+
if (!pkg.scripts.knip && !pkg.scripts['check:unused']) {
|
|
769
|
+
pkg.scripts.knip = 'knip'
|
|
770
|
+
pkg.scripts['knip:fix'] = 'knip --fix'
|
|
771
|
+
changed = true
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (changed) {
|
|
775
|
+
writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
|
|
776
|
+
console.log(' â
Added knip scripts to package.json')
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.log(' đĻ Run: npm install --save-dev knip')
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// âââ License Audit ââââââââââââââââââââââââââââââââââââââââââ
|
|
784
|
+
|
|
785
|
+
async function setupLicenseAudit(options) {
|
|
786
|
+
console.log('đ Setting up license compliance checking...')
|
|
787
|
+
|
|
788
|
+
// Add license check script
|
|
789
|
+
const packagePath = join(projectRoot, 'package.json')
|
|
790
|
+
if (existsSync(packagePath)) {
|
|
791
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
|
|
792
|
+
pkg.scripts = pkg.scripts || {}
|
|
793
|
+
let changed = false
|
|
794
|
+
|
|
795
|
+
if (!pkg.scripts['check:licenses']) {
|
|
796
|
+
pkg.scripts['check:licenses'] = 'license-checker --production --failOn "GPL-2.0;GPL-3.0;AGPL-1.0;AGPL-3.0" --excludePrivatePackages'
|
|
797
|
+
changed = true
|
|
798
|
+
}
|
|
799
|
+
if (!pkg.scripts['licenses:summary']) {
|
|
800
|
+
pkg.scripts['licenses:summary'] = 'license-checker --production --summary'
|
|
801
|
+
changed = true
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (changed) {
|
|
805
|
+
writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
|
|
806
|
+
console.log(' â
Added license check scripts to package.json')
|
|
807
|
+
console.log(' âšī¸ Blocked licenses: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0')
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
console.log(' đĻ Run: npm install --save-dev license-checker')
|
|
812
|
+
}
|
|
813
|
+
|
|
722
814
|
program.parse()
|
|
@@ -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,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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Health Checks â All
|
|
2
|
+
* Health Checks â All 28 project health checks
|
|
3
3
|
*
|
|
4
4
|
* Main entry: scanProjectHealth(projectPath, projectName, options?)
|
|
5
5
|
* Individual checks available via named imports.
|
|
@@ -32,3 +32,8 @@ export { check as checkCoverageThresholds } from './coverage-thresholds.js'
|
|
|
32
32
|
export { check as checkEslintSecurity } from './eslint-security.js'
|
|
33
33
|
export { check as checkDependencyCruiser } from './dependency-cruiser.js'
|
|
34
34
|
export { check as checkConventionalCommits } from './conventional-commits.js'
|
|
35
|
+
export { check as checkKnip } from './knip.js'
|
|
36
|
+
export { check as checkDependencyAutomation } from './dependency-automation.js'
|
|
37
|
+
export { check as checkLicenseAudit } from './license-audit.js'
|
|
38
|
+
export { check as checkSast } from './sast.js'
|
|
39
|
+
export { check as checkBundleSize } from './bundle-size.js'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: Knip (Dead Code Detection)
|
|
3
|
+
*
|
|
4
|
+
* Checks for unused files, exports, and dependencies via Knip.
|
|
5
|
+
* Score: up to 3 points:
|
|
6
|
+
* +1 for knip installed (in dependencies)
|
|
7
|
+
* +1 for knip config file exists (knip.json, knip.config.ts, etc.)
|
|
8
|
+
* +1 for knip script in package.json (e.g. check:unused, knip)
|
|
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('knip', 3, {
|
|
17
|
+
installed: false,
|
|
18
|
+
configFound: false,
|
|
19
|
+
configPath: null,
|
|
20
|
+
hasScript: false,
|
|
21
|
+
scriptName: null,
|
|
22
|
+
hasAlternative: false,
|
|
23
|
+
alternativeTool: null,
|
|
24
|
+
message: ''
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Check for knip config files
|
|
28
|
+
const knipConfigs = [
|
|
29
|
+
'knip.json',
|
|
30
|
+
'knip.jsonc',
|
|
31
|
+
'knip.config.ts',
|
|
32
|
+
'knip.config.js',
|
|
33
|
+
'knip.config.mjs',
|
|
34
|
+
'knip.config.cjs',
|
|
35
|
+
'.knip.json',
|
|
36
|
+
'.knip.jsonc',
|
|
37
|
+
// Sub-packages
|
|
38
|
+
'backend/knip.json',
|
|
39
|
+
'backend/knip.config.ts',
|
|
40
|
+
'frontend/knip.json',
|
|
41
|
+
'frontend/knip.config.ts'
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
for (const config of knipConfigs) {
|
|
45
|
+
if (existsSync(join(projectPath, config))) {
|
|
46
|
+
result.details.configFound = true
|
|
47
|
+
result.details.configPath = config
|
|
48
|
+
result.score += 1
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check package.json for knip in dependencies and scripts
|
|
54
|
+
const packageLocations = [
|
|
55
|
+
'package.json',
|
|
56
|
+
'backend/package.json',
|
|
57
|
+
'frontend/package.json'
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for (const loc of packageLocations) {
|
|
61
|
+
const pkgPath = join(projectPath, loc)
|
|
62
|
+
if (!existsSync(pkgPath)) continue
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
66
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
67
|
+
|
|
68
|
+
if (allDeps.knip) {
|
|
69
|
+
result.details.installed = true
|
|
70
|
+
result.score += 1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Also check for knip key in package.json (inline config)
|
|
74
|
+
if (pkg.knip && !result.details.configFound) {
|
|
75
|
+
result.details.configFound = true
|
|
76
|
+
result.details.configPath = `${loc} (knip key)`
|
|
77
|
+
result.score += 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for knip script
|
|
81
|
+
const scripts = pkg.scripts || {}
|
|
82
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
83
|
+
if (typeof cmd !== 'string') continue
|
|
84
|
+
if (cmd.includes('knip') || name === 'knip') {
|
|
85
|
+
result.details.hasScript = true
|
|
86
|
+
result.details.scriptName = `${loc} â ${name}`
|
|
87
|
+
result.score += 1
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check for alternative dead code tools
|
|
93
|
+
if (allDeps['ts-prune'] || allDeps['unimported']) {
|
|
94
|
+
result.details.hasAlternative = true
|
|
95
|
+
result.details.alternativeTool = allDeps['ts-prune'] ? 'ts-prune' : 'unimported'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check for custom unused code scripts (partial credit)
|
|
99
|
+
if (!result.details.hasScript) {
|
|
100
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
101
|
+
if (typeof cmd !== 'string') continue
|
|
102
|
+
if (name.includes('unused') || name.includes('dead-code') ||
|
|
103
|
+
cmd.includes('unused') || cmd.includes('check-unused')) {
|
|
104
|
+
result.details.hasScript = true
|
|
105
|
+
result.details.scriptName = `${loc} â ${name} (custom)`
|
|
106
|
+
result.score += 1
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch { /* ignore */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
115
|
+
|
|
116
|
+
// Determine status
|
|
117
|
+
if (result.score === 0) {
|
|
118
|
+
if (result.details.hasAlternative) {
|
|
119
|
+
result.status = 'warning'
|
|
120
|
+
result.details.message = `Using ${result.details.alternativeTool} â consider migrating to Knip (more comprehensive)`
|
|
121
|
+
} else {
|
|
122
|
+
result.status = 'error'
|
|
123
|
+
result.details.message = 'No dead code detection configured â unused files and exports go undetected'
|
|
124
|
+
}
|
|
125
|
+
} else if (result.score < 2) {
|
|
126
|
+
result.status = 'warning'
|
|
127
|
+
result.details.message = result.details.installed
|
|
128
|
+
? 'Knip installed but no config or script'
|
|
129
|
+
: 'Dead code script exists but Knip not installed'
|
|
130
|
+
} else {
|
|
131
|
+
result.details.message = 'Dead code detection well-configured'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: License Audit
|
|
3
|
+
*
|
|
4
|
+
* Checks for dependency license compliance tooling.
|
|
5
|
+
* Score: up to 2 points:
|
|
6
|
+
* +1 for license-checker or license-report tool configured
|
|
7
|
+
* +1 for forbidden license list or license check 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('license-audit', 2, {
|
|
16
|
+
toolInstalled: false,
|
|
17
|
+
toolName: null,
|
|
18
|
+
hasConfig: false,
|
|
19
|
+
configPath: null,
|
|
20
|
+
hasScript: false,
|
|
21
|
+
scriptName: null,
|
|
22
|
+
hasForbiddenList: false,
|
|
23
|
+
message: ''
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// License tools to check for
|
|
27
|
+
const licenseTools = [
|
|
28
|
+
'license-checker',
|
|
29
|
+
'license-checker-rspack',
|
|
30
|
+
'license-report',
|
|
31
|
+
'license-webpack-plugin',
|
|
32
|
+
'license-compliance',
|
|
33
|
+
'legally',
|
|
34
|
+
'nlf'
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// Check package.json files for license tools and scripts
|
|
38
|
+
const packageLocations = [
|
|
39
|
+
'package.json',
|
|
40
|
+
'backend/package.json',
|
|
41
|
+
'frontend/package.json'
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
for (const loc of packageLocations) {
|
|
45
|
+
const pkgPath = join(projectPath, loc)
|
|
46
|
+
if (!existsSync(pkgPath)) continue
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
50
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
51
|
+
|
|
52
|
+
// Check for license tools
|
|
53
|
+
for (const tool of licenseTools) {
|
|
54
|
+
if (allDeps[tool]) {
|
|
55
|
+
result.details.toolInstalled = true
|
|
56
|
+
result.details.toolName = tool
|
|
57
|
+
result.score += 1
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for license scripts
|
|
63
|
+
const scripts = pkg.scripts || {}
|
|
64
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
65
|
+
if (typeof cmd !== 'string') continue
|
|
66
|
+
if (name.includes('license') || cmd.includes('license-checker') ||
|
|
67
|
+
cmd.includes('license-report') || cmd.includes('license-compliance')) {
|
|
68
|
+
result.details.hasScript = true
|
|
69
|
+
result.details.scriptName = `${loc} â ${name}`
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check for forbidden license patterns in scripts
|
|
75
|
+
for (const [, cmd] of Object.entries(scripts)) {
|
|
76
|
+
if (typeof cmd !== 'string') continue
|
|
77
|
+
if (cmd.includes('--failOn') || cmd.includes('--excludeLicenses') ||
|
|
78
|
+
cmd.includes('--production') || cmd.includes('forbidden')) {
|
|
79
|
+
result.details.hasForbiddenList = true
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch { /* ignore */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check for license config files
|
|
87
|
+
const licenseConfigs = [
|
|
88
|
+
'.licensechecker.json',
|
|
89
|
+
'.license-checker.json',
|
|
90
|
+
'license-checker-config.json',
|
|
91
|
+
'.license-report-config.json'
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
for (const config of licenseConfigs) {
|
|
95
|
+
const configPath = join(projectPath, config)
|
|
96
|
+
if (!existsSync(configPath)) continue
|
|
97
|
+
|
|
98
|
+
result.details.hasConfig = true
|
|
99
|
+
result.details.configPath = config
|
|
100
|
+
result.score += 1
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
104
|
+
if (content.includes('GPL') || content.includes('forbidden') ||
|
|
105
|
+
content.includes('excludeLicenses') || content.includes('failOn')) {
|
|
106
|
+
result.details.hasForbiddenList = true
|
|
107
|
+
}
|
|
108
|
+
} catch { /* ignore */ }
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Script or forbidden list counts toward second point
|
|
113
|
+
if (!result.details.hasConfig && (result.details.hasScript || result.details.hasForbiddenList)) {
|
|
114
|
+
result.score += 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
118
|
+
|
|
119
|
+
// Determine status
|
|
120
|
+
if (result.score === 0) {
|
|
121
|
+
result.status = 'warning'
|
|
122
|
+
result.details.message = 'No license compliance tooling â GPL dependencies could go unnoticed'
|
|
123
|
+
} else if (result.score < 2) {
|
|
124
|
+
result.status = 'warning'
|
|
125
|
+
result.details.message = result.details.toolInstalled
|
|
126
|
+
? `${result.details.toolName} installed but no forbidden license enforcement`
|
|
127
|
+
: 'License check script exists but no dedicated tool'
|
|
128
|
+
} else {
|
|
129
|
+
result.details.message = `License compliance enforced via ${result.details.toolName || 'config'}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result
|
|
133
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check: SAST (Static Application Security Testing)
|
|
3
|
+
*
|
|
4
|
+
* Checks for SAST tooling like Semgrep, CodeQL, Snyk Code, or SonarQube.
|
|
5
|
+
* Enterprise security requirement â catches OWASP patterns statically.
|
|
6
|
+
* Score: up to 2 points:
|
|
7
|
+
* +1 for SAST tool configured (config file or CI workflow)
|
|
8
|
+
* +1 for SAST running in CI (GitHub Actions workflow)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
12
|
+
import { join } from 'path'
|
|
13
|
+
import { createCheck } from './types.js'
|
|
14
|
+
|
|
15
|
+
export async function check(projectPath) {
|
|
16
|
+
const result = createCheck('sast', 2, {
|
|
17
|
+
tool: null,
|
|
18
|
+
configFound: false,
|
|
19
|
+
configPath: null,
|
|
20
|
+
ciIntegration: false,
|
|
21
|
+
ciWorkflow: null,
|
|
22
|
+
message: ''
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Check for Semgrep config
|
|
26
|
+
const semgrepConfigs = [
|
|
27
|
+
'.semgrep.yml',
|
|
28
|
+
'.semgrep.yaml',
|
|
29
|
+
'.semgrep/',
|
|
30
|
+
'semgrep.yml',
|
|
31
|
+
'semgrep.yaml'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for (const config of semgrepConfigs) {
|
|
35
|
+
if (existsSync(join(projectPath, config))) {
|
|
36
|
+
result.details.tool = 'semgrep'
|
|
37
|
+
result.details.configFound = true
|
|
38
|
+
result.details.configPath = config
|
|
39
|
+
result.score += 1
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for CodeQL config
|
|
45
|
+
if (!result.details.configFound) {
|
|
46
|
+
const codeqlConfigs = [
|
|
47
|
+
'.github/codeql/codeql-config.yml',
|
|
48
|
+
'.github/codeql-config.yml',
|
|
49
|
+
'codeql-config.yml'
|
|
50
|
+
]
|
|
51
|
+
for (const config of codeqlConfigs) {
|
|
52
|
+
if (existsSync(join(projectPath, config))) {
|
|
53
|
+
result.details.tool = 'codeql'
|
|
54
|
+
result.details.configFound = true
|
|
55
|
+
result.details.configPath = config
|
|
56
|
+
result.score += 1
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for SonarQube/SonarCloud config
|
|
63
|
+
if (!result.details.configFound) {
|
|
64
|
+
const sonarConfigs = [
|
|
65
|
+
'sonar-project.properties',
|
|
66
|
+
'.sonarcloud.properties'
|
|
67
|
+
]
|
|
68
|
+
for (const config of sonarConfigs) {
|
|
69
|
+
if (existsSync(join(projectPath, config))) {
|
|
70
|
+
result.details.tool = 'sonarqube'
|
|
71
|
+
result.details.configFound = true
|
|
72
|
+
result.details.configPath = config
|
|
73
|
+
result.score += 1
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check for Snyk config
|
|
80
|
+
if (!result.details.configFound) {
|
|
81
|
+
if (existsSync(join(projectPath, '.snyk'))) {
|
|
82
|
+
result.details.tool = 'snyk'
|
|
83
|
+
result.details.configFound = true
|
|
84
|
+
result.details.configPath = '.snyk'
|
|
85
|
+
result.score += 1
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check CI workflows for SAST integration
|
|
90
|
+
const workflowDir = join(projectPath, '.github/workflows')
|
|
91
|
+
if (existsSync(workflowDir)) {
|
|
92
|
+
try {
|
|
93
|
+
const files = readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
try {
|
|
96
|
+
const content = readFileSync(join(workflowDir, file), 'utf-8')
|
|
97
|
+
const lower = content.toLowerCase()
|
|
98
|
+
|
|
99
|
+
if (lower.includes('semgrep') || lower.includes('codeql') ||
|
|
100
|
+
lower.includes('sonarcloud') || lower.includes('sonarqube') ||
|
|
101
|
+
lower.includes('snyk') || lower.includes('returntocorp/semgrep') ||
|
|
102
|
+
lower.includes('github/codeql-action') || lower.includes('sonarsource')) {
|
|
103
|
+
result.details.ciIntegration = true
|
|
104
|
+
result.details.ciWorkflow = file
|
|
105
|
+
result.score += 1
|
|
106
|
+
|
|
107
|
+
// Detect tool from CI if not already found
|
|
108
|
+
if (!result.details.tool) {
|
|
109
|
+
if (lower.includes('semgrep')) result.details.tool = 'semgrep'
|
|
110
|
+
else if (lower.includes('codeql')) result.details.tool = 'codeql'
|
|
111
|
+
else if (lower.includes('sonar')) result.details.tool = 'sonarqube'
|
|
112
|
+
else if (lower.includes('snyk')) result.details.tool = 'snyk'
|
|
113
|
+
result.details.configFound = true
|
|
114
|
+
}
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
} catch { /* ignore individual file errors */ }
|
|
118
|
+
}
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check package.json for SAST-related dependencies or scripts
|
|
123
|
+
if (!result.details.configFound) {
|
|
124
|
+
const pkgPath = join(projectPath, 'package.json')
|
|
125
|
+
if (existsSync(pkgPath)) {
|
|
126
|
+
try {
|
|
127
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
128
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
129
|
+
const scripts = pkg.scripts || {}
|
|
130
|
+
|
|
131
|
+
if (allDeps['snyk']) {
|
|
132
|
+
result.details.tool = 'snyk'
|
|
133
|
+
result.details.configFound = true
|
|
134
|
+
result.score += 1
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
138
|
+
if (typeof cmd !== 'string') continue
|
|
139
|
+
if (cmd.includes('semgrep') || cmd.includes('snyk code') ||
|
|
140
|
+
name.includes('sast') || name.includes('security:scan')) {
|
|
141
|
+
result.details.configFound = true
|
|
142
|
+
result.details.configPath = `package.json â ${name}`
|
|
143
|
+
if (!result.details.tool) result.details.tool = 'custom'
|
|
144
|
+
result.score += 1
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch { /* ignore */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
result.score = Math.min(result.score, result.maxScore)
|
|
153
|
+
|
|
154
|
+
// Determine status
|
|
155
|
+
if (result.score === 0) {
|
|
156
|
+
result.status = 'warning'
|
|
157
|
+
result.details.message = 'No SAST tooling â OWASP patterns not detected statically'
|
|
158
|
+
} else if (result.score < 2) {
|
|
159
|
+
result.status = 'warning'
|
|
160
|
+
result.details.message = `${result.details.tool} configured but not integrated in CI`
|
|
161
|
+
} else {
|
|
162
|
+
result.details.message = `SAST enabled via ${result.details.tool} (CI integrated)`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project Health Scanner
|
|
3
3
|
*
|
|
4
|
-
* Orchestrates all
|
|
4
|
+
* Orchestrates all 28 health checks and produces a HealthReport.
|
|
5
5
|
* This is the main entry point â consumers call scanProjectHealth().
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -28,6 +28,11 @@ import { check as checkCoverageThresholds } from './coverage-thresholds.js'
|
|
|
28
28
|
import { check as checkEslintSecurity } from './eslint-security.js'
|
|
29
29
|
import { check as checkDependencyCruiser } from './dependency-cruiser.js'
|
|
30
30
|
import { check as checkConventionalCommits } from './conventional-commits.js'
|
|
31
|
+
import { check as checkKnip } from './knip.js'
|
|
32
|
+
import { check as checkDependencyAutomation } from './dependency-automation.js'
|
|
33
|
+
import { check as checkLicenseAudit } from './license-audit.js'
|
|
34
|
+
import { check as checkSast } from './sast.js'
|
|
35
|
+
import { check as checkBundleSize } from './bundle-size.js'
|
|
31
36
|
import { calculateHealthStatus } from './types.js'
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -64,7 +69,12 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
|
|
|
64
69
|
checkCoverageThresholds(projectPath),
|
|
65
70
|
checkEslintSecurity(projectPath),
|
|
66
71
|
checkDependencyCruiser(projectPath),
|
|
67
|
-
checkConventionalCommits(projectPath)
|
|
72
|
+
checkConventionalCommits(projectPath),
|
|
73
|
+
checkKnip(projectPath),
|
|
74
|
+
checkDependencyAutomation(projectPath),
|
|
75
|
+
checkLicenseAudit(projectPath),
|
|
76
|
+
checkSast(projectPath),
|
|
77
|
+
checkBundleSize(projectPath)
|
|
68
78
|
])
|
|
69
79
|
|
|
70
80
|
const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'} HealthCheckType
|
|
8
|
+
* @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'} HealthCheckType
|
|
9
9
|
*
|
|
10
10
|
* @typedef {'ok'|'warning'|'error'} HealthStatus
|
|
11
11
|
*
|