@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.
@@ -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 23 project health checks
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 23 health checks and produces a HealthReport.
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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },