@soulbatical/tetra-dev-toolkit 1.3.3 → 1.4.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.
@@ -9,10 +9,16 @@
9
9
  * - Configuration file
10
10
  *
11
11
  * Usage:
12
- * tetra-setup # Interactive setup
12
+ * tetra-setup # Interactive setup (hooks + ci + config)
13
13
  * tetra-setup hooks # Setup Husky hooks only
14
14
  * tetra-setup ci # Setup GitHub Actions only
15
15
  * tetra-setup config # Create .tetra-quality.json
16
+ * tetra-setup prettier # Setup Prettier + lint-staged
17
+ * tetra-setup eslint-security # Setup ESLint security plugins
18
+ * tetra-setup coverage # Setup test coverage thresholds
19
+ * tetra-setup lighthouse # Setup Lighthouse CI budgets
20
+ * tetra-setup commitlint # Setup commitlint + commit-msg hook
21
+ * tetra-setup depcruiser # Setup dependency-cruiser
16
22
  */
17
23
 
18
24
  import { program } from 'commander'
@@ -26,7 +32,7 @@ program
26
32
  .name('tetra-setup')
27
33
  .description('Setup Tetra Dev Toolkit in your project')
28
34
  .version('1.2.0')
29
- .argument('[component]', 'Component to setup: hooks, ci, config, or all (default)')
35
+ .argument('[component]', 'Component to setup: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser, or all')
30
36
  .option('-f, --force', 'Overwrite existing files')
31
37
  .action(async (component, options) => {
32
38
  console.log('')
@@ -49,8 +55,27 @@ program
49
55
  case 'config':
50
56
  await setupConfig(options)
51
57
  break
58
+ case 'prettier':
59
+ await setupPrettier(options)
60
+ break
61
+ case 'eslint-security':
62
+ await setupEslintSecurity(options)
63
+ break
64
+ case 'coverage':
65
+ await setupCoverage(options)
66
+ break
67
+ case 'lighthouse':
68
+ await setupLighthouse(options)
69
+ break
70
+ case 'commitlint':
71
+ await setupCommitlint(options)
72
+ break
73
+ case 'depcruiser':
74
+ await setupDepCruiser(options)
75
+ break
52
76
  default:
53
77
  console.log(`Unknown component: ${comp}`)
78
+ console.log('Available: hooks, ci, config, prettier, eslint-security, coverage, lighthouse, commitlint, depcruiser')
54
79
  }
55
80
  }
56
81
 
@@ -258,4 +283,440 @@ async function setupConfig(options) {
258
283
  }
259
284
  }
260
285
 
286
+ // ─── Prettier ───────────────────────────────────────────────────
287
+
288
+ async function setupPrettier(options) {
289
+ console.log('🎨 Setting up Prettier + lint-staged...')
290
+
291
+ const prettierPath = join(projectRoot, '.prettierrc.json')
292
+ if (!existsSync(prettierPath) || options.force) {
293
+ const prettierConfig = {
294
+ semi: true,
295
+ trailingComma: 'es5',
296
+ singleQuote: true,
297
+ printWidth: 100,
298
+ tabWidth: 2,
299
+ useTabs: false,
300
+ arrowParens: 'always',
301
+ endOfLine: 'lf'
302
+ }
303
+ writeFileSync(prettierPath, JSON.stringify(prettierConfig, null, 2) + '\n')
304
+ console.log(' ✅ Created .prettierrc.json')
305
+ } else {
306
+ console.log(' ⏭️ .prettierrc.json already exists')
307
+ }
308
+
309
+ const ignorePath = join(projectRoot, '.prettierignore')
310
+ if (!existsSync(ignorePath) || options.force) {
311
+ const ignoreContent = `node_modules
312
+ dist
313
+ build
314
+ .next
315
+ coverage
316
+ *.min.js
317
+ *.min.css
318
+ package-lock.json
319
+ `
320
+ writeFileSync(ignorePath, ignoreContent)
321
+ console.log(' ✅ Created .prettierignore')
322
+ }
323
+
324
+ // Add lint-staged config
325
+ const lintStagedPath = join(projectRoot, '.lintstagedrc.json')
326
+ if (!existsSync(lintStagedPath) || options.force) {
327
+ const lintStagedConfig = {
328
+ '*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
329
+ '*.{json,css,scss,md,yml,yaml}': ['prettier --write']
330
+ }
331
+ writeFileSync(lintStagedPath, JSON.stringify(lintStagedConfig, null, 2) + '\n')
332
+ console.log(' ✅ Created .lintstagedrc.json')
333
+ }
334
+
335
+ // Add scripts to package.json
336
+ const packagePath = join(projectRoot, 'package.json')
337
+ if (existsSync(packagePath)) {
338
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
339
+ pkg.scripts = pkg.scripts || {}
340
+ let changed = false
341
+
342
+ if (!pkg.scripts.format) {
343
+ pkg.scripts.format = 'prettier --write .'
344
+ changed = true
345
+ }
346
+ if (!pkg.scripts['format:check']) {
347
+ pkg.scripts['format:check'] = 'prettier --check .'
348
+ changed = true
349
+ }
350
+
351
+ if (changed) {
352
+ writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
353
+ console.log(' ✅ Added format scripts to package.json')
354
+ }
355
+ }
356
+
357
+ console.log(' 📦 Run: npm install --save-dev prettier lint-staged')
358
+ }
359
+
360
+ // ─── ESLint Security ────────────────────────────────────────────
361
+
362
+ async function setupEslintSecurity(options) {
363
+ console.log('🔒 Setting up ESLint security plugins...')
364
+
365
+ // Check for existing ESLint config
366
+ const eslintConfigs = [
367
+ '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json',
368
+ 'eslint.config.js', 'eslint.config.mjs',
369
+ 'backend/.eslintrc.js', 'backend/eslint.config.js'
370
+ ]
371
+
372
+ let hasEslint = false
373
+ for (const config of eslintConfigs) {
374
+ if (existsSync(join(projectRoot, config))) {
375
+ hasEslint = true
376
+ console.log(` Found existing config: ${config}`)
377
+ break
378
+ }
379
+ }
380
+
381
+ if (!hasEslint) {
382
+ // Create a new ESLint flat config with security plugins
383
+ const eslintPath = join(projectRoot, 'eslint.config.js')
384
+ if (!existsSync(eslintPath) || options.force) {
385
+ const eslintContent = `import js from '@eslint/js'
386
+ import security from 'eslint-plugin-security'
387
+ import sonarjs from 'eslint-plugin-sonarjs'
388
+
389
+ export default [
390
+ js.configs.recommended,
391
+ {
392
+ plugins: {
393
+ security,
394
+ sonarjs
395
+ },
396
+ rules: {
397
+ // Security rules
398
+ 'security/detect-unsafe-regex': 'error',
399
+ 'security/detect-eval-with-expression': 'error',
400
+ 'security/detect-no-csrf-before-method-override': 'error',
401
+ 'security/detect-non-literal-regexp': 'warn',
402
+ 'security/detect-object-injection': 'off', // too many false positives
403
+ 'security/detect-possible-timing-attacks': 'warn',
404
+
405
+ // SonarJS rules
406
+ 'sonarjs/cognitive-complexity': ['warn', 25],
407
+ 'sonarjs/no-duplicate-string': ['warn', { threshold: 5 }],
408
+ 'sonarjs/slow-regex': 'warn',
409
+
410
+ // General safety
411
+ 'no-eval': 'error',
412
+ 'no-implied-eval': 'error',
413
+ 'no-new-func': 'error'
414
+ }
415
+ }
416
+ ]
417
+ `
418
+ writeFileSync(eslintPath, eslintContent)
419
+ console.log(' ✅ Created eslint.config.js with security plugins')
420
+ }
421
+ } else {
422
+ console.log(' ⚠️ ESLint config exists — manually add these plugins:')
423
+ console.log(' - eslint-plugin-security')
424
+ console.log(' - eslint-plugin-sonarjs')
425
+ }
426
+
427
+ console.log(' 📦 Run: npm install --save-dev eslint eslint-plugin-security eslint-plugin-sonarjs')
428
+ }
429
+
430
+ // ─── Coverage Thresholds ────────────────────────────────────────
431
+
432
+ async function setupCoverage(options) {
433
+ console.log('📊 Setting up test coverage thresholds...')
434
+
435
+ // Detect test framework
436
+ const packagePath = join(projectRoot, 'package.json')
437
+ let framework = null
438
+
439
+ const packageLocations = ['package.json', 'backend/package.json']
440
+ for (const loc of packageLocations) {
441
+ const pkgPath = join(projectRoot, loc)
442
+ if (!existsSync(pkgPath)) continue
443
+
444
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
445
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
446
+ if (allDeps.vitest) { framework = 'vitest'; break }
447
+ if (allDeps.jest) { framework = 'jest'; break }
448
+ }
449
+
450
+ if (!framework) {
451
+ console.log(' ⚠️ No test framework detected. Install vitest or jest first.')
452
+ console.log(' 📦 Recommended: npm install --save-dev vitest @vitest/coverage-v8')
453
+ return
454
+ }
455
+
456
+ if (framework === 'vitest') {
457
+ const vitestPath = join(projectRoot, 'vitest.config.ts')
458
+ if (!existsSync(vitestPath) || options.force) {
459
+ const vitestContent = `import { defineConfig } from 'vitest/config'
460
+
461
+ export default defineConfig({
462
+ test: {
463
+ coverage: {
464
+ provider: 'v8',
465
+ reporter: ['text', 'lcov', 'html'],
466
+ reportsDirectory: './coverage',
467
+ thresholds: {
468
+ statements: 70,
469
+ branches: 60,
470
+ functions: 70,
471
+ lines: 70
472
+ }
473
+ }
474
+ }
475
+ })
476
+ `
477
+ writeFileSync(vitestPath, vitestContent)
478
+ console.log(' ✅ Created vitest.config.ts with coverage thresholds (70/60/70/70)')
479
+ }
480
+ console.log(' 📦 Run: npm install --save-dev @vitest/coverage-v8')
481
+ }
482
+
483
+ if (framework === 'jest') {
484
+ console.log(' ℹ️ Add to your jest.config.js:')
485
+ console.log(`
486
+ coverageThreshold: {
487
+ global: {
488
+ statements: 70,
489
+ branches: 60,
490
+ functions: 70,
491
+ lines: 70
492
+ }
493
+ }
494
+ `)
495
+ }
496
+
497
+ // Add coverage script
498
+ if (existsSync(packagePath)) {
499
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
500
+ pkg.scripts = pkg.scripts || {}
501
+ if (!pkg.scripts['test:coverage']) {
502
+ pkg.scripts['test:coverage'] = framework === 'vitest'
503
+ ? 'vitest run --coverage'
504
+ : 'jest --coverage'
505
+ writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
506
+ console.log(' ✅ Added test:coverage script to package.json')
507
+ }
508
+ }
509
+ }
510
+
511
+ // ─── Lighthouse ─────────────────────────────────────────────────
512
+
513
+ async function setupLighthouse(options) {
514
+ console.log('🏎️ Setting up Lighthouse CI...')
515
+
516
+ const lighthousercPath = join(projectRoot, 'lighthouserc.json')
517
+ if (!existsSync(lighthousercPath) || options.force) {
518
+ const config = {
519
+ ci: {
520
+ collect: {
521
+ numberOfRuns: 3,
522
+ settings: {
523
+ preset: 'desktop'
524
+ }
525
+ },
526
+ assert: {
527
+ assertions: {
528
+ 'categories:performance': ['warn', { minScore: 0.7 }],
529
+ 'categories:seo': ['error', { minScore: 0.9 }],
530
+ 'categories:accessibility': ['warn', { minScore: 0.85 }],
531
+ 'categories:best-practices': ['warn', { minScore: 0.8 }],
532
+ 'first-contentful-paint': ['warn', { maxNumericValue: 2500 }],
533
+ 'largest-contentful-paint': ['warn', { maxNumericValue: 3500 }],
534
+ 'cumulative-layout-shift': ['warn', { maxNumericValue: 0.1 }],
535
+ 'total-blocking-time': ['warn', { maxNumericValue: 500 }]
536
+ }
537
+ }
538
+ }
539
+ }
540
+ writeFileSync(lighthousercPath, JSON.stringify(config, null, 2) + '\n')
541
+ console.log(' ✅ Created lighthouserc.json')
542
+ }
543
+
544
+ const budgetPath = join(projectRoot, 'lighthouse-budget.json')
545
+ if (!existsSync(budgetPath) || options.force) {
546
+ const budget = [{
547
+ path: '/*',
548
+ resourceSizes: [
549
+ { resourceType: 'script', budget: 500 },
550
+ { resourceType: 'image', budget: 300 },
551
+ { resourceType: 'stylesheet', budget: 100 },
552
+ { resourceType: 'font', budget: 100 },
553
+ { resourceType: 'total', budget: 1500 }
554
+ ],
555
+ resourceCounts: [
556
+ { resourceType: 'third-party', budget: 15 }
557
+ ]
558
+ }]
559
+ writeFileSync(budgetPath, JSON.stringify(budget, null, 2) + '\n')
560
+ console.log(' ✅ Created lighthouse-budget.json')
561
+ }
562
+
563
+ // Create GitHub Actions workflow
564
+ const workflowDir = join(projectRoot, '.github/workflows')
565
+ if (!existsSync(workflowDir)) mkdirSync(workflowDir, { recursive: true })
566
+
567
+ const workflowPath = join(workflowDir, 'lighthouse.yml')
568
+ if (!existsSync(workflowPath) || options.force) {
569
+ const workflow = `name: Lighthouse CI
570
+
571
+ on:
572
+ push:
573
+ branches: [main, master]
574
+ pull_request:
575
+ branches: [main, master]
576
+
577
+ jobs:
578
+ lighthouse:
579
+ name: Lighthouse Audit
580
+ runs-on: ubuntu-latest
581
+ steps:
582
+ - uses: actions/checkout@v4
583
+ - name: Audit URLs
584
+ uses: treosh/lighthouse-ci-action@v12
585
+ with:
586
+ configPath: ./lighthouserc.json
587
+ budgetPath: ./lighthouse-budget.json
588
+ uploadArtifacts: true
589
+ temporaryPublicStorage: true
590
+ `
591
+ writeFileSync(workflowPath, workflow)
592
+ console.log(' ✅ Created .github/workflows/lighthouse.yml')
593
+ }
594
+
595
+ console.log(' ℹ️ Update lighthouserc.json with your production URLs')
596
+ }
597
+
598
+ // ─── Commitlint ─────────────────────────────────────────────────
599
+
600
+ async function setupCommitlint(options) {
601
+ console.log('📝 Setting up commitlint...')
602
+
603
+ const commitlintPath = join(projectRoot, 'commitlint.config.js')
604
+ if (!existsSync(commitlintPath) || options.force) {
605
+ const content = `export default {
606
+ extends: ['@commitlint/config-conventional'],
607
+ rules: {
608
+ 'type-enum': [2, 'always', [
609
+ 'feat', 'fix', 'docs', 'style', 'refactor',
610
+ 'perf', 'test', 'build', 'ci', 'chore', 'security'
611
+ ]],
612
+ 'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
613
+ 'header-max-length': [2, 'always', 100]
614
+ }
615
+ }
616
+ `
617
+ writeFileSync(commitlintPath, content)
618
+ console.log(' ✅ Created commitlint.config.js')
619
+ }
620
+
621
+ // Add commit-msg hook
622
+ const huskyDir = join(projectRoot, '.husky')
623
+ if (existsSync(huskyDir)) {
624
+ const commitMsgPath = join(huskyDir, 'commit-msg')
625
+ if (!existsSync(commitMsgPath) || options.force) {
626
+ const hookContent = `#!/bin/sh
627
+ npx --no -- commitlint --edit \${1}
628
+ `
629
+ writeFileSync(commitMsgPath, hookContent)
630
+ execSync(`chmod +x ${commitMsgPath}`)
631
+ console.log(' ✅ Created .husky/commit-msg hook')
632
+ }
633
+ } else {
634
+ console.log(' ⚠️ No .husky directory — run "tetra-setup hooks" first')
635
+ }
636
+
637
+ console.log(' 📦 Run: npm install --save-dev @commitlint/cli @commitlint/config-conventional')
638
+ }
639
+
640
+ // ─── Dependency Cruiser ─────────────────────────────────────────
641
+
642
+ async function setupDepCruiser(options) {
643
+ console.log('🔍 Setting up dependency-cruiser...')
644
+
645
+ const configPath = join(projectRoot, '.dependency-cruiser.cjs')
646
+ if (!existsSync(configPath) || options.force) {
647
+ const content = `/** @type {import('dependency-cruiser').IConfiguration} */
648
+ module.exports = {
649
+ forbidden: [
650
+ {
651
+ name: 'no-circular',
652
+ severity: 'error',
653
+ comment: 'Circular dependencies cause maintenance problems and unexpected behavior',
654
+ from: {},
655
+ to: { circular: true }
656
+ },
657
+ {
658
+ name: 'no-orphans',
659
+ severity: 'info',
660
+ comment: 'Modules without incoming or outgoing connections',
661
+ from: { orphan: true, pathNot: ['\\\\.(test|spec)\\\\.(ts|js)$', 'types\\\\.ts$'] },
662
+ to: {}
663
+ },
664
+ {
665
+ name: 'no-dev-deps-in-src',
666
+ severity: 'error',
667
+ comment: 'devDependencies should not be imported in production code',
668
+ from: { path: '^src', pathNot: '\\\\.(test|spec)\\\\.' },
669
+ to: { dependencyTypes: ['npm-dev'] }
670
+ },
671
+ {
672
+ name: 'no-deprecated-core',
673
+ severity: 'warn',
674
+ comment: 'Deprecated Node.js core modules',
675
+ from: {},
676
+ to: { dependencyTypes: ['core'], path: '^(punycode|domain|constants|sys|_linklist|_stream_wrap)$' }
677
+ }
678
+ ],
679
+ options: {
680
+ doNotFollow: { path: 'node_modules' },
681
+ tsPreCompilationDeps: true,
682
+ tsConfig: { fileName: 'tsconfig.json' },
683
+ enhancedResolveOptions: {
684
+ exportsFields: ['exports'],
685
+ conditionNames: ['import', 'require', 'node', 'default']
686
+ },
687
+ reporterOptions: {
688
+ dot: { collapsePattern: 'node_modules/[^/]+' }
689
+ }
690
+ }
691
+ }
692
+ `
693
+ writeFileSync(configPath, content)
694
+ console.log(' ✅ Created .dependency-cruiser.cjs')
695
+ }
696
+
697
+ // Add scripts
698
+ const packagePath = join(projectRoot, 'package.json')
699
+ if (existsSync(packagePath)) {
700
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
701
+ pkg.scripts = pkg.scripts || {}
702
+ let changed = false
703
+
704
+ if (!pkg.scripts['check:deps']) {
705
+ pkg.scripts['check:deps'] = 'depcruise src --config .dependency-cruiser.cjs'
706
+ changed = true
707
+ }
708
+ if (!pkg.scripts['check:circular']) {
709
+ pkg.scripts['check:circular'] = 'depcruise src --config .dependency-cruiser.cjs --output-type err-only'
710
+ changed = true
711
+ }
712
+
713
+ if (changed) {
714
+ writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
715
+ console.log(' ✅ Added check:deps and check:circular scripts')
716
+ }
717
+ }
718
+
719
+ console.log(' 📦 Run: npm install --save-dev dependency-cruiser')
720
+ }
721
+
261
722
  program.parse()
@@ -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
+ }