@boyingliu01/xp-gate 0.14.13 → 0.14.15

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/hooks/pre-commit CHANGED
@@ -339,240 +339,334 @@ if [ -z "$CHANGED_FILES" ]; then
339
339
  exit 0
340
340
  fi
341
341
 
342
- # Determine project language stack
343
- PROJECT_LANG=""
344
- if [ -f "package.json" ]; then
345
- # Check if it's a React Native project
346
- HAS_RN=$(grep -q "react-native" package.json 2>/dev/null && echo "yes" || echo "no")
347
- HAS_IOS=$([ -d "ios" ] && echo "yes" || echo "no")
348
- HAS_ANDROID=$([ -d "android" ] && echo "yes" || echo "no")
349
- if [ "$HAS_RN" = "yes" ] || { [ "$HAS_IOS" = "yes" ] && [ "$HAS_ANDROID" = "yes" ]; }; then
350
- PROJECT_LANG="react-native"
351
- else
352
- PROJECT_LANG="typescript"
342
+ # ============================================================================
343
+ # Multi-language detection (v0.15.0+): detect all languages from changed file
344
+ # extensions, with fallback to manifest-based detection.
345
+ # Result: PROJECT_LANGS (space-separated string), PROJECT_LANG (first language for
346
+ # compatibility), PROJECT_SUBDIR (derived from changed file paths in monorepos;
347
+ # v0.15.1+; ARCH-03 fix), PROJECT_SUBDIRS (per-language subdir override, empty = root).
348
+ # ============================================================================
349
+
350
+ # XP_GATE_LANG env var override — skip all detection, use single language
351
+ if [ -n "${XP_GATE_LANG:-}" ]; then
352
+ PROJECT_LANGS="$XP_GATE_LANG"
353
+ PROJECT_LANG="$XP_GATE_LANG"
354
+ echo ""
355
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
356
+ echo " 🔧 XP_GATE_LANG OVERRIDE: $PROJECT_LANG"
357
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
358
+ echo ""
359
+ else
360
+ # ---- Extension → language mapping ----
361
+ # Extracts unique languages from changed file extensions
362
+ PROJECT_LANGS=""
363
+ _detect_ext_lang() {
364
+ for ext in "$@"; do
365
+ if echo "$CHANGED_FILES" | grep -qE "\.${ext}$" 2>/dev/null; then
366
+ echo "1"
367
+ return 0
368
+ fi
369
+ done
370
+ echo "0"
371
+ return 1
372
+ }
373
+
374
+ # Check changed files for each language by extension
375
+ if [ "$(_detect_ext_lang "ts" "tsx" "js" "jsx" "mjs")" = "1" ]; then
376
+ PROJECT_LANGS="$PROJECT_LANGS typescript"
353
377
  fi
354
- elif [ -f "pyproject.toml" ] || [ -f "setup.py" ] || [ -f "requirements.txt" ]; then
355
- PROJECT_LANG="python"
356
- elif [ -f "go.mod" ]; then
357
- PROJECT_LANG="go"
358
- elif [ -f "pubspec.yaml" ]; then
359
- # Check if it's a Flutter project
360
- if grep -q "flutter:" pubspec.yaml 2>/dev/null || [ -f ".metadata" ]; then
361
- PROJECT_LANG="flutter"
362
- else
363
- PROJECT_LANG="dart"
378
+ if [ "$(_detect_ext_lang "py")" = "1" ]; then
379
+ PROJECT_LANGS="$PROJECT_LANGS python"
364
380
  fi
365
- elif [ -n "$(find . -name '*.ps1' -not -path './.git/*' -maxdepth 2 | head -1)" ]; then
366
- PROJECT_LANG="powershell"
367
- elif [ -f "CMakeLists.txt" ] || [ -f "Makefile" ]; then
368
- CPP_FILES=$(find . -maxdepth 2 \( -name "*.cpp" -o -name "*.cxx" -o -name "*.cc" \) -not -path "./.git/*" 2>/dev/null | head -1)
369
- OBJC_FILES=$(find . -maxdepth 2 \( -name "*.m" -o -name "*.mm" \) -not -path "./.git/*" 2>/dev/null | head -1)
370
- if [ -n "$CPP_FILES" ]; then
371
- PROJECT_LANG="cpp"
372
- elif [ -n "$OBJC_FILES" ]; then
373
- PROJECT_LANG="objectivec"
381
+ if [ "$(_detect_ext_lang "go")" = "1" ]; then
382
+ PROJECT_LANGS="$PROJECT_LANGS go"
374
383
  fi
375
- elif [ -f "*.xcodeproj" ] || [ -d "*.xcworkspace" ]; then
376
- # Xcode project - Objective-C or Swift
377
- OBJC_FILES=$(find . -maxdepth 2 \( -name "*.m" -o -name "*.mm" \) -not -path "./.git/*" 2>/dev/null | head -1)
378
- if [ -n "$OBJC_FILES" ]; then
379
- PROJECT_LANG="objectivec"
384
+ if [ "$(_detect_ext_lang "java")" = "1" ]; then
385
+ PROJECT_LANGS="$PROJECT_LANGS java"
380
386
  fi
381
- elif [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
382
- # Check if it's Kotlin or Java
383
- KOTLIN_FILES=$(find . -name "*.kt" -not -path "./.git/*" 2>/dev/null | wc -l)
384
- if [ "$KOTLIN_FILES" -gt 0 ]; then
385
- PROJECT_LANG="kotlin"
386
- else
387
- PROJECT_LANG="java"
387
+ if [ "$(_detect_ext_lang "kt" "kts")" = "1" ]; then
388
+ PROJECT_LANGS="$PROJECT_LANGS kotlin"
388
389
  fi
389
- elif [ -n "$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" 2>/dev/null | head -1)" ] || [ -f "*.ps1" ]; then
390
- PROJECT_LANG="powershell"
391
- fi
392
-
393
- # If not found at root, search subdirectories (max depth 2) for project manifests
394
- # This supports mixed/hybrid project structures like:
395
- # root: Documentation + scripts
396
- # subdirectory: Flutter/TypeScript/Python/Go project
397
- # v0.15.1+ (ARCH-03 fix): Also derive PROJECT_SUBDIR from changed file paths
398
- # when root-level config exists but actual project is in a subdirectory.
399
- PROJECT_SUBDIR=""
400
- if [ -z "$PROJECT_LANG" ]; then
401
- # Search for TypeScript/JavaScript projects (excluding React Native)
402
- TSCONFIG_SUB=$(find . -maxdepth 2 -name "tsconfig.json" -not -path "./.git/*" -not -path "./node_modules/*" 2>/dev/null | head -1)
403
- if [ -n "$TSCONFIG_SUB" ]; then
404
- PROJECT_SUBDIR=$(dirname "$TSCONFIG_SUB")
405
- # Check if it's React Native
406
- if [ -f "$PROJECT_SUBDIR/package.json" ] && grep -q "react-native" "$PROJECT_SUBDIR/package.json" 2>/dev/null; then
407
- PROJECT_LANG="react-native"
390
+ if [ "$(_detect_ext_lang "dart")" = "1" ]; then
391
+ # Check for Flutter: if pubspec.yaml exists and contains flutter
392
+ if [ -f "pubspec.yaml" ] && grep -q "flutter:" pubspec.yaml 2>/dev/null; then
393
+ PROJECT_LANGS="$PROJECT_LANGS flutter"
408
394
  else
409
- PROJECT_LANG="typescript"
395
+ PROJECT_LANGS="$PROJECT_LANGS dart"
410
396
  fi
411
397
  fi
412
- else
413
- # v0.15.1+ (ARCH-03 fix): PROJECT_LANG was set from root config, but in a monorepo
414
- # the actual project may be in a subdirectory. Derive PROJECT_SUBDIR from changed files.
415
- _detect_subdir_pkg() {
416
- local fdir
417
- fdir=$(dirname "$1")
418
- while [ "$fdir" != "." ] && [ "$fdir" != "/" ]; do
419
- case "$fdir" in
420
- */node_modules/*) fdir="${fdir%/*}" ; continue ;;
421
- */.git/*) fdir="${fdir%/*}" ; continue ;;
422
- esac
423
- if [ -f "$fdir/tsconfig.json" ] || [ -f "$fdir/package.json" ] || \
424
- [ -f "$fdir/pyproject.toml" ] || [ -f "$fdir/setup.py" ] || \
425
- [ -f "$fdir/go.mod" ] || [ -f "$fdir/pubspec.yaml" ] || \
426
- [ -f "$fdir/pom.xml" ] || [ -f "$fdir/build.gradle" ] || \
427
- [ -f "$fdir/build.gradle.kts" ] || [ -f "$fdir/CMakeLists.txt" ]; then
428
- echo "$fdir"
429
- return 0
398
+ if [ "$(_detect_ext_lang "swift")" = "1" ]; then
399
+ PROJECT_LANGS="$PROJECT_LANGS swift"
400
+ fi
401
+ if [ "$(_detect_ext_lang "m" "mm")" = "1" ]; then
402
+ PROJECT_LANGS="$PROJECT_LANGS objectivec"
403
+ fi
404
+ if [ "$(_detect_ext_lang "cpp" "cxx" "cc" "c" "hpp" "h")" = "1" ]; then
405
+ PROJECT_LANGS="$PROJECT_LANGS cpp"
406
+ fi
407
+ if [ "$(_detect_ext_lang "sh")" = "1" ]; then
408
+ PROJECT_LANGS="$PROJECT_LANGS shell"
409
+ fi
410
+ if [ "$(_detect_ext_lang "ps1" "psm1")" = "1" ]; then
411
+ PROJECT_LANGS="$PROJECT_LANGS powershell"
412
+ fi
413
+ # IaC: .tf, .tfvars, Dockerfile, docker-compose*, .yaml/.yml in kube context
414
+ if echo "$CHANGED_FILES" | grep -qE '(\.tf$|\.tfvars$|Dockerfile|docker-compose)' 2>/dev/null; then
415
+ PROJECT_LANGS="$PROJECT_LANGS iac"
416
+ fi
417
+
418
+ # Trim leading space
419
+ PROJECT_LANGS="${PROJECT_LANGS# }"
420
+
421
+ # ---- Fallback: manifest-based detection when no extension matches ----
422
+ if [ -z "$PROJECT_LANGS" ]; then
423
+ if [ -f "package.json" ]; then
424
+ HAS_RN=$(grep -q "react-native" package.json 2>/dev/null && echo "yes" || echo "no")
425
+ HAS_IOS=$([ -d "ios" ] && echo "yes" || echo "no")
426
+ HAS_ANDROID=$([ -d "android" ] && echo "yes" || echo "no")
427
+ if [ "$HAS_RN" = "yes" ] || { [ "$HAS_IOS" = "yes" ] && [ "$HAS_ANDROID" = "yes" ]; }; then
428
+ PROJECT_LANGS="react-native"
429
+ else
430
+ PROJECT_LANGS="typescript"
430
431
  fi
431
- case "$fdir" in
432
- */*) fdir="${fdir%/*}" ;;
433
- *) break ;;
434
- esac
435
- done
436
- return 1
437
- }
438
- _subdir_set=""
439
- while IFS= read -r _changed; do
440
- [ -z "$_changed" ] && continue
441
- _d=$(_detect_subdir_pkg "$_changed" 2>/dev/null) || true
442
- if [ -n "$_d" ] && [ "$_d" != "." ]; then
443
- case " $_subdir_set " in
444
- *" $_d "*) ;;
445
- *) _subdir_set="${_subdir_set} $_d" ;;
446
- esac
432
+ elif [ -f "pyproject.toml" ] || [ -f "setup.py" ] || [ -f "requirements.txt" ]; then
433
+ PROJECT_LANGS="python"
434
+ elif [ -f "go.mod" ]; then
435
+ PROJECT_LANGS="go"
436
+ elif [ -f "pubspec.yaml" ]; then
437
+ if grep -q "flutter:" pubspec.yaml 2>/dev/null || [ -f ".metadata" ]; then
438
+ PROJECT_LANGS="flutter"
439
+ else
440
+ PROJECT_LANGS="dart"
441
+ fi
442
+ elif [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
443
+ KOTLIN_FILES=$(find . -name "*.kt" -not -path "./.git/*" 2>/dev/null | wc -l)
444
+ if [ "$KOTLIN_FILES" -gt 0 ]; then
445
+ PROJECT_LANGS="kotlin"
446
+ else
447
+ PROJECT_LANGS="java"
448
+ fi
449
+ elif [ -f "CMakeLists.txt" ] || [ -f "Makefile" ]; then
450
+ CPP_FILES=$(find . -maxdepth 2 \( -name "*.cpp" -o -name "*.cxx" -o -name "*.cc" \) -not -path "./.git/*" 2>/dev/null | head -1)
451
+ OBJC_FILES=$(find . -maxdepth 2 \( -name "*.m" -o -name "*.mm" \) -not -path "./.git/*" 2>/dev/null | head -1)
452
+ if [ -n "$CPP_FILES" ]; then
453
+ PROJECT_LANGS="cpp"
454
+ elif [ -n "$OBJC_FILES" ]; then
455
+ PROJECT_LANGS="objectivec"
456
+ fi
457
+ elif [ -n "$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" 2>/dev/null | head -1)" ]; then
458
+ PROJECT_LANGS="powershell"
447
459
  fi
448
- done <<CHANGEDFILELIST
460
+ fi
461
+
462
+ # ---- Derive PROJECT_SUBDIR from changed file paths (v0.15.1+; ARCH-03 fix) ----
463
+ # Extension-based detection sets PROJECT_LANGS but leaves PROJECT_SUBDIR empty.
464
+ # In a monorepo where config files (tsconfig.json, pyproject.toml, ...) live in
465
+ # subdirectories (e.g. packages/frontend/), we must derive the subdirectory from
466
+ # the actual changed files so that Gates 1-9 run in the correct context.
467
+ PROJECT_SUBDIR=""
468
+ if [ -n "$PROJECT_LANGS" ]; then
469
+ # Walk up from each changed file to find the nearest project marker.
470
+ _detect_subdir() {
471
+ local fdir
472
+ fdir=$(dirname "$1")
473
+ while [ "$fdir" != "." ] && [ "$fdir" != "/" ]; do
474
+ # Skip node_modules, .git, and other non-project directories
475
+ case "$fdir" in
476
+ */node_modules/*) fdir="${fdir%/*}" ; continue ;;
477
+ */.git/*) fdir="${fdir%/*}" ; continue ;;
478
+ esac
479
+ if [ -f "$fdir/tsconfig.json" ] || [ -f "$fdir/package.json" ] || \
480
+ [ -f "$fdir/pyproject.toml" ] || [ -f "$fdir/setup.py" ] || \
481
+ [ -f "$fdir/go.mod" ] || [ -f "$fdir/pubspec.yaml" ] || \
482
+ [ -f "$fdir/pom.xml" ] || [ -f "$fdir/build.gradle" ] || \
483
+ [ -f "$fdir/build.gradle.kts" ] || [ -f "$fdir/CMakeLists.txt" ]; then
484
+ echo "$fdir"
485
+ return 0
486
+ fi
487
+ case "$fdir" in
488
+ */*) fdir="${fdir%/*}" ;;
489
+ *) break ;;
490
+ esac
491
+ done
492
+ return 1
493
+ }
494
+ _subdir_set=""
495
+ while IFS= read -r _changed; do
496
+ [ -z "$_changed" ] && continue
497
+ _d=$(_detect_subdir "$_changed" 2>/dev/null) || true
498
+ if [ -n "$_d" ] && [ "$_d" != "." ]; then
499
+ case " $_subdir_set " in
500
+ *" $_d "*) ;; # already seen
501
+ *) _subdir_set="${_subdir_set} $_d" ;;
502
+ esac
503
+ fi
504
+ done <<CHANGEDFILELIST
449
505
  $CHANGED_FILES
450
506
  CHANGEDFILELIST
451
- _subdir_count=$(echo "$_subdir_set" | awk '{ n=0; for(i=1;i<=NF;i++) n++; print n }')
452
- if [ "$_subdir_count" = "1" ]; then
453
- PROJECT_SUBDIR=$(echo "$_subdir_set" | awk '{print $1}')
454
- echo ""
455
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
456
- echo " 📁 SUBDIR DETECTED FROM CHANGED FILES (ARCH-03 fix)"
457
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
458
- echo ""
459
- echo "Language: $PROJECT_LANG"
460
- echo "Location: $PROJECT_SUBDIR/"
461
- echo ""
507
+ _subdir_count=$(echo "$_subdir_set" | awk '{ n=0; for(i=1;i<=NF;i++) n++; print n }')
508
+ if [ "$_subdir_count" = "1" ]; then
509
+ PROJECT_SUBDIR=$(echo "$_subdir_set" | awk '{print $1}')
510
+ echo ""
511
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
512
+ echo " 📁 MULTILANGUAGE SUBDIR DETECTED FROM CHANGED FILES"
513
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
514
+ echo ""
515
+ echo "Language: $PROJECT_LANGS"
516
+ echo "Location: $PROJECT_SUBDIR/"
517
+ echo ""
518
+ fi
462
519
  fi
463
- fi
464
520
 
465
- if [ -z "$PROJECT_LANG" ]; then
466
- # Search for Python projects
467
- if [ -z "$PROJECT_LANG" ]; then
468
- PYPROJECT_SUB=$(find . -maxdepth 2 -name "pyproject.toml" -not -path "./.git/*" 2>/dev/null | head -1)
469
- if [ -n "$PYPROJECT_SUB" ]; then
470
- PROJECT_LANG="python"
471
- PROJECT_SUBDIR=$(dirname "$PYPROJECT_SUB")
521
+ # ---- Subdirectory fallback (for single-language projects in subdirectories) ----
522
+ # Only used when extension-based detection found nothing
523
+ if [ -z "$PROJECT_LANGS" ]; then
524
+ # Search for TypeScript/JavaScript projects
525
+ TSCONFIG_SUB=$(find . -maxdepth 2 -name "tsconfig.json" -not -path "./.git/*" -not -path "./node_modules/*" 2>/dev/null | head -1)
526
+ if [ -n "$TSCONFIG_SUB" ]; then
527
+ PROJECT_SUBDIR=$(dirname "$TSCONFIG_SUB")
528
+ if [ -f "$PROJECT_SUBDIR/package.json" ] && grep -q "react-native" "$PROJECT_SUBDIR/package.json" 2>/dev/null; then
529
+ PROJECT_LANGS="react-native"
530
+ else
531
+ PROJECT_LANGS="typescript"
532
+ fi
472
533
  fi
473
- fi
474
-
475
- # Search for Go projects
476
- if [ -z "$PROJECT_LANG" ]; then
477
- GOMOD_SUB=$(find . -maxdepth 2 -name "go.mod" -not -path "./.git/*" 2>/dev/null | head -1)
478
- if [ -n "$GOMOD_SUB" ]; then
479
- PROJECT_LANG="go"
480
- PROJECT_SUBDIR=$(dirname "$GOMOD_SUB")
534
+
535
+ # Search for Python projects
536
+ if [ -z "$PROJECT_LANGS" ]; then
537
+ PYPROJECT_SUB=$(find . -maxdepth 2 -name "pyproject.toml" -not -path "./.git/*" 2>/dev/null | head -1)
538
+ if [ -n "$PYPROJECT_SUB" ]; then
539
+ PROJECT_LANGS="python"
540
+ PROJECT_SUBDIR=$(dirname "$PYPROJECT_SUB")
541
+ fi
481
542
  fi
482
- fi
483
-
484
- # Search for Flutter/Dart projects
485
- if [ -z "$PROJECT_LANG" ]; then
486
- PUBSPEC_SUB=$(find . -maxdepth 2 -name "pubspec.yaml" -not -path "./.git/*" 2>/dev/null | head -1)
487
- if [ -n "$PUBSPEC_SUB" ]; then
488
- if grep -q "flutter:" "$PUBSPEC_SUB" 2>/dev/null || [ -f "$(dirname "$PUBSPEC_SUB")/.metadata" ]; then
489
- PROJECT_LANG="flutter"
490
- else
491
- PROJECT_LANG="dart"
543
+
544
+ # Search for Go projects
545
+ if [ -z "$PROJECT_LANGS" ]; then
546
+ GOMOD_SUB=$(find . -maxdepth 2 -name "go.mod" -not -path "./.git/*" 2>/dev/null | head -1)
547
+ if [ -n "$GOMOD_SUB" ]; then
548
+ PROJECT_LANGS="go"
549
+ PROJECT_SUBDIR=$(dirname "$GOMOD_SUB")
492
550
  fi
493
- PROJECT_SUBDIR=$(dirname "$PUBSPEC_SUB")
494
551
  fi
495
- fi
496
-
497
- # Search for Java/Kotlin projects
498
- if [ -z "$PROJECT_LANG" ]; then
499
- POM_SUB=$(find . -maxdepth 2 -name "pom.xml" -not -path "./.git/*" 2>/dev/null | head -1)
500
- GRADLE_SUB=$(find . -maxdepth 2 -name "build.gradle" -not -path "./.git/*" 2>/dev/null | head -1)
501
- if [ -n "$POM_SUB" ] || [ -n "$GRADLE_SUB" ]; then
502
- MANIFEST_FILE="${POM_SUB:-$GRADLE_SUB}"
503
- PROJECT_SUBDIR=$(dirname "$MANIFEST_FILE")
504
- # Check for Kotlin files in subdirectory
505
- KOTLIN_COUNT=$(find "$PROJECT_SUBDIR" -name "*.kt" -not -path "*/.git/*" 2>/dev/null | wc -l)
506
- if [ "$KOTLIN_COUNT" -gt 0 ]; then
507
- PROJECT_LANG="kotlin"
508
- else
509
- PROJECT_LANG="java"
552
+
553
+ # Search for Flutter/Dart projects
554
+ if [ -z "$PROJECT_LANGS" ]; then
555
+ PUBSPEC_SUB=$(find . -maxdepth 2 -name "pubspec.yaml" -not -path "./.git/*" 2>/dev/null | head -1)
556
+ if [ -n "$PUBSPEC_SUB" ]; then
557
+ if grep -q "flutter:" "$PUBSPEC_SUB" 2>/dev/null || [ -f "$(dirname "$PUBSPEC_SUB")/.metadata" ]; then
558
+ PROJECT_LANGS="flutter"
559
+ else
560
+ PROJECT_LANGS="dart"
561
+ fi
562
+ PROJECT_SUBDIR=$(dirname "$PUBSPEC_SUB")
510
563
  fi
511
564
  fi
512
- fi
513
-
514
- # Search for C++ projects
515
- if [ -z "$PROJECT_LANG" ]; then
516
- CMAKE_SUB=$(find . -maxdepth 2 -name "CMakeLists.txt" -not -path "./.git/*" 2>/dev/null | head -1)
517
- if [ -n "$CMAKE_SUB" ]; then
518
- PROJECT_LANG="cpp"
519
- PROJECT_SUBDIR=$(dirname "$CMAKE_SUB")
565
+
566
+ # Search for Java/Kotlin projects
567
+ if [ -z "$PROJECT_LANGS" ]; then
568
+ POM_SUB=$(find . -maxdepth 2 -name "pom.xml" -not -path "./.git/*" 2>/dev/null | head -1)
569
+ GRADLE_SUB=$(find . -maxdepth 2 -name "build.gradle" -not -path "./.git/*" 2>/dev/null | head -1)
570
+ if [ -n "$POM_SUB" ] || [ -n "$GRADLE_SUB" ]; then
571
+ MANIFEST_FILE="${POM_SUB:-$GRADLE_SUB}"
572
+ PROJECT_SUBDIR=$(dirname "$MANIFEST_FILE")
573
+ KOTLIN_COUNT=$(find "$PROJECT_SUBDIR" -name "*.kt" -not -path "*/.git/*" 2>/dev/null | wc -l)
574
+ if [ "$KOTLIN_COUNT" -gt 0 ]; then
575
+ PROJECT_LANGS="kotlin"
576
+ else
577
+ PROJECT_LANGS="java"
578
+ fi
579
+ fi
520
580
  fi
521
- fi
522
-
523
- # Search for React Native projects (ios/ + android/ directories)
524
- if [ -z "$PROJECT_LANG" ]; then
525
- RN_SUB=$(find . -maxdepth 2 -type d -name "ios" -not -path "./.git/*" 2>/dev/null | head -1)
526
- if [ -n "$RN_SUB" ]; then
527
- PROJECT_SUBDIR=$(dirname "$RN_SUB")
528
- if [ -d "$PROJECT_SUBDIR/android" ] || [ -f "$PROJECT_SUBDIR/package.json" ]; then
529
- PROJECT_LANG="react-native"
581
+
582
+ # Search for C++ projects
583
+ if [ -z "$PROJECT_LANGS" ]; then
584
+ CMAKE_SUB=$(find . -maxdepth 2 -name "CMakeLists.txt" -not -path "./.git/*" 2>/dev/null | head -1)
585
+ if [ -n "$CMAKE_SUB" ]; then
586
+ PROJECT_LANGS="cpp"
587
+ PROJECT_SUBDIR=$(dirname "$CMAKE_SUB")
530
588
  fi
531
589
  fi
532
- fi
533
-
534
- # Search for PowerShell projects
535
- if [ -z "$PROJECT_LANG" ]; then
536
- PS_SUB=$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" 2>/dev/null | head -1)
537
- if [ -n "$PS_SUB" ]; then
538
- PROJECT_LANG="powershell"
539
- PROJECT_SUBDIR=$(dirname "$PS_SUB")
590
+
591
+ # Search for React Native projects (ios/ + android/ directories)
592
+ if [ -z "$PROJECT_LANGS" ]; then
593
+ RN_SUB=$(find . -maxdepth 2 -type d -name "ios" -not -path "./.git/*" 2>/dev/null | head -1)
594
+ if [ -n "$RN_SUB" ]; then
595
+ PROJECT_SUBDIR=$(dirname "$RN_SUB")
596
+ if [ -d "$PROJECT_SUBDIR/android" ] || [ -f "$PROJECT_SUBDIR/package.json" ]; then
597
+ PROJECT_LANGS="react-native"
598
+ fi
599
+ fi
600
+ fi
601
+
602
+ # Search for PowerShell projects
603
+ if [ -z "$PROJECT_LANGS" ]; then
604
+ PS_SUB=$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" 2>/dev/null | head -1)
605
+ if [ -n "$PS_SUB" ]; then
606
+ PROJECT_LANGS="powershell"
607
+ PROJECT_SUBDIR=$(dirname "$PS_SUB")
608
+ fi
609
+ fi
610
+
611
+ # Report detected subdirectory project
612
+ if [ -n "$PROJECT_SUBDIR" ] && [ -n "$PROJECT_LANGS" ]; then
613
+ echo ""
614
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
615
+ echo " 📁 SUBDIRECTORY PROJECT DETECTED"
616
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
617
+ echo ""
618
+ echo "Language: $PROJECT_LANGS"
619
+ echo "Location: $PROJECT_SUBDIR/"
620
+ echo ""
540
621
  fi
541
622
  fi
542
-
543
- # Report detected subdirectory project
544
- if [ -n "$PROJECT_SUBDIR" ] && [ -n "$PROJECT_LANG" ]; then
545
- echo ""
546
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
547
- echo " 📁 SUBDIRECTORY PROJECT DETECTED"
548
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
549
- echo ""
550
- echo "Language: $PROJECT_LANG"
551
- echo "Location: $PROJECT_SUBDIR/"
552
- echo ""
623
+
624
+ # Set PROJECT_LANG for backward compatibility (first language in list)
625
+ PROJECT_LANG=$(echo "$PROJECT_LANGS" | awk '{print $1}')
626
+
627
+ # ---- Documentation-only fallback ----
628
+ if [ -z "$PROJECT_LANGS" ]; then
629
+ CODE_FILES=$(find . -type f \( -name "*.py" -o -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.java" -o -name "*.go" -o -name "*.rs" -o -name "*.cpp" -o -name "*.c" -o -name "*.swift" -o -name "*.kt" -o -name "*.sh" -o -name "*.dart" -o -name "*.ps1" \) -not -path "./.git/*" 2>/dev/null | wc -l)
630
+
631
+ if [ "$CODE_FILES" -eq 0 ]; then
632
+ PROJECT_LANGS="documentation-only"
633
+ PROJECT_LANG="documentation-only"
634
+ echo ""
635
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
636
+ echo " 📚 DOCUMENTATION-ONLY PROJECT DETECTED"
637
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
638
+ echo ""
639
+ echo "This project contains no source code files."
640
+ echo "Skipping static analysis and test gates."
641
+ echo ""
642
+ echo "Proceeding with documentation-only checks..."
643
+ fi
553
644
  fi
554
- fi
555
645
 
556
- # Check if this is a documentation-only project (no source code)
557
- # Such projects have only markdown, yaml, json, skills, docs - no actual code
558
- if [ -z "$PROJECT_LANG" ]; then
559
- # Count source code files (including shell scripts)
560
- CODE_FILES=$(find . -type f \( -name "*.py" -o -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.java" -o -name "*.go" -o -name "*.rs" -o -name "*.cpp" -o -name "*.c" -o -name "*.swift" -o -name "*.kt" -o -name "*.sh" -o -name "*.dart" -o -name "*.ps1" \) -not -path "./.git/*" 2>/dev/null | wc -l)
561
-
562
- if [ "$CODE_FILES" -eq 0 ]; then
563
- PROJECT_LANG="documentation-only"
646
+ # ---- Multi-language project banner ----
647
+ _lang_count=$(echo "$PROJECT_LANGS" | wc -w)
648
+ if [ "$_lang_count" -gt 1 ]; then
564
649
  echo ""
565
650
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
566
- echo " 📚 DOCUMENTATION-ONLY PROJECT DETECTED"
651
+ echo " 🌐 MULTI-LANGUAGE PROJECT DETECTED"
567
652
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
568
653
  echo ""
569
- echo "This project contains no source code files."
570
- echo "Skipping static analysis and test gates."
654
+ echo "Detected $_lang_count languages from changed files:"
655
+ for _l in $PROJECT_LANGS; do
656
+ echo " • $_l"
657
+ done
571
658
  echo ""
572
- echo "Proceeding with documentation-only checks..."
573
659
  fi
574
660
  fi
575
661
 
662
+ # Helper: check if a language is in PROJECT_LANGS
663
+ has_project_lang() {
664
+ for lang in $PROJECT_LANGS; do
665
+ if [ "$lang" = "$1" ]; then return 0; fi
666
+ done
667
+ return 1
668
+ }
669
+
576
670
  # ============================================================================
577
671
  # Audit helper — records gate execution to .xp-gate/audit.jsonl
578
672
  # NEVER blocks commit on failure (try/catch around npx tsx).
@@ -619,10 +713,11 @@ gate_start_ms() {
619
713
  date +%s%3N 2>/dev/null || python3 -c "import time;print(int(time.time()*1000))" 2>/dev/null || echo "0"
620
714
  }
621
715
 
622
- # Switch to subdirectory if detected (for mixed/hybrid projects)
623
- # All subsequent gates will run in the subdirectory context
716
+ # Switch to subdirectory if detected (for single-language subdirectory projects).
717
+ # Multi-language projects stay at root — each adapter handles its own path.
624
718
  ORIGINAL_DIR=""
625
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ]; then
719
+ _lang_count=$(echo "$PROJECT_LANGS" | wc -w)
720
+ if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
626
721
  ORIGINAL_DIR=$(pwd)
627
722
  cd "$PROJECT_SUBDIR"
628
723
  echo "Working in: $PROJECT_SUBDIR/"
@@ -632,16 +727,24 @@ fi
632
727
  # ============================================================================
633
728
  # GATE 1: Code Quality (Static Analysis + Linting + Shell Check combined)
634
729
  # Combination of: Old Gates 1 (Static), Gate 2 (Linting), Gate 5 (Shell Check)
730
+ # v0.14.3+ (Issue #312): Skip if no code files changed
635
731
  # ============================================================================
636
732
  2>&1 echo ""
637
733
  2>&1 echo "→ Gate 1: Code Quality (static analysis + linting)..."
734
+ if ! any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|mm|dart|sh)$" "\.ya?ml$" "\.json$" "Dockerfile" "\.tf$"; then
735
+ echo "✅ PASSED - No code files changed, skipping lint."
736
+ else
638
737
  GATE_1_START=$(gate_start_ms)
639
738
 
640
739
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
641
740
  # Documentation-only project - skip static analysis
642
741
  echo "⏭️ SKIPPED - Code quality (documentation project)."
643
742
 
644
- elif [ "$PROJECT_LANG" = "typescript" ]; then
743
+ else
744
+ for CURRENT_LANG in $PROJECT_LANGS; do
745
+ echo " ── Checking $CURRENT_LANG ──"
746
+ case "$CURRENT_LANG" in
747
+ typescript)
645
748
  # TypeScript: static analysis (tsc) + linting (eslint)
646
749
  if ! command -v npx &> /dev/null; then
647
750
  echo "ℹ️ npx not available — skipping TypeScript checks"
@@ -798,8 +901,9 @@ if [ "$PROJECT_LANG" = "documentation-only" ]; then
798
901
  fi
799
902
  fi
800
903
  fi
801
-
802
- elif [ "$PROJECT_LANG" = "python" ]; then
904
+ ;;
905
+
906
+ python)
803
907
  # Python: Ruff (includes syntax + linting) + mypy (optional typing)
804
908
  if ! command -v ruff &> /dev/null; then
805
909
  echo "ℹ️ ruff not available — skipping Python linting"
@@ -878,8 +982,9 @@ elif [ "$PROJECT_LANG" = "python" ]; then
878
982
  echo "ℹ️ Mypy not available - Skipping optional type check"
879
983
  fi
880
984
  fi
881
-
882
- elif [ "$PROJECT_LANG" = "go" ]; then
985
+ ;;
986
+
987
+ go)
883
988
  # Go: go vet + golangci-lint
884
989
  if ! command -v golangci-lint &> /dev/null; then
885
990
  echo "ℹ️ golangci-lint not available — skipping Go linting"
@@ -896,8 +1001,9 @@ elif [ "$PROJECT_LANG" = "go" ]; then
896
1001
  fi
897
1002
  echo "✅ PASSED - golangci-lint."
898
1003
  fi
899
-
900
- elif [ "$PROJECT_LANG" = "shell" ] || echo "$CHANGED_FILES" | grep -qE '\.sh$'; then
1004
+ ;;
1005
+
1006
+ shell)
901
1007
  # Shell: check staged shell files only. Installed hook adapters are infrastructure,
902
1008
  # not project changes, and should not be linted on unrelated commits.
903
1009
  SHELL_FILES=$(echo "$CHANGED_FILES" | grep '\.sh$' || true)
@@ -967,39 +1073,44 @@ elif [ "$PROJECT_LANG" = "shell" ] || echo "$CHANGED_FILES" | grep -qE '\.sh$';
967
1073
  done
968
1074
  echo "✅ PASSED - Basic shell script syntax check."
969
1075
  fi
970
-
971
- else
972
- # Route to appropriate adapter for other languages
973
- ADAPTER_FILE=$(resolve_adapter_path "$PROJECT_LANG")
974
- if [ -n "$ADAPTER_FILE" ] && source "$ADAPTER_FILE" 2>/dev/null; then
975
- echo "Running static analysis for $PROJECT_LANG..."
976
- run_static_analysis | head -30 2>/dev/null
977
- ANALYSIS_EXIT=$?
978
- if [ "$ANALYSIS_EXIT" -ne 0 ]; then
979
- echo "⚠️ Static analysis had issues (may be non-blocking)"
980
- fi
1076
+ ;;
981
1077
 
982
- echo "Running lint check for $PROJECT_LANG..."
983
- run_lint | head -30 2>/dev/null
984
- LINT_EXIT=$?
985
- if [ "$LINT_EXIT" -ne 0 ]; then
986
- echo "ℹ️ Lint check had issues (may be non-blocking)"
987
- fi
1078
+ *)
1079
+ # Route to appropriate adapter for other languages
1080
+ ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
1081
+ if [ -n "$ADAPTER_FILE" ] && source "$ADAPTER_FILE" 2>/dev/null; then
1082
+ echo "Running static analysis for $CURRENT_LANG..."
1083
+ run_static_analysis | head -30 2>/dev/null
1084
+ ANALYSIS_EXIT=$?
1085
+ if [ "$ANALYSIS_EXIT" -ne 0 ]; then
1086
+ echo "⚠️ Static analysis had issues (may be non-blocking)"
1087
+ fi
988
1088
 
989
- if [ "$ANALYSIS_EXIT" -eq 0 ] && [ "$LINT_EXIT" -eq 0 ]; then
990
- echo "✅ PASSED - Language-specific code quality checks."
991
- else
992
- # Tools unavailable or failed — skip non-blockingly
993
- echo "⏭️ SKIPPED - Code quality (tools unavailable or failed)"
994
- fi
995
- else
996
- # If no adapter exists for this language, skip
997
- echo "ℹ️ No specific adapter for $PROJECT_LANG — using generic checks if any"
998
- echo "⏭️ SKIPPED - Code quality (no specific checks for $PROJECT_LANG)"
999
- fi
1089
+ echo "Running lint check for $CURRENT_LANG..."
1090
+ run_lint | head -30 2>/dev/null
1091
+ LINT_EXIT=$?
1092
+ if [ "$LINT_EXIT" -ne 0 ]; then
1093
+ echo "ℹ️ Lint check had issues (may be non-blocking)"
1094
+ fi
1095
+
1096
+ if [ "$ANALYSIS_EXIT" -eq 0 ] && [ "$LINT_EXIT" -eq 0 ]; then
1097
+ echo "✅ PASSED - Language-specific code quality checks."
1098
+ else
1099
+ # Tools unavailable or failed — skip non-blockingly
1100
+ echo "⏭️ SKIPPED - Code quality (tools unavailable or failed)"
1101
+ fi
1102
+ else
1103
+ # If no adapter exists for this language, skip
1104
+ echo "ℹ️ No specific adapter for $CURRENT_LANG — using generic checks if any"
1105
+ echo "⏭️ SKIPPED - Code quality (no specific checks for $CURRENT_LANG)"
1106
+ fi
1107
+ ;;
1108
+ esac
1109
+ done
1000
1110
  fi
1111
+ fi # End: if ! any_changed_files_match (Issue #312 skip)
1001
1112
  GATE_1_STATUS="PASS"
1002
- GATE_1_TOOL="${PROJECT_LANG}"
1113
+ GATE_1_TOOL="${PROJECT_LANGS}"
1003
1114
  record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START"
1004
1115
 
1005
1116
  # ============================================================================
@@ -1013,7 +1124,11 @@ GATE_2_START=$(gate_start_ms)
1013
1124
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
1014
1125
  echo "⏭️ SKIPPED - Duplicate code (documentation project)."
1015
1126
 
1016
- elif [ "$PROJECT_LANG" = "typescript" ]; then
1127
+ else
1128
+ for CURRENT_LANG in $PROJECT_LANGS; do
1129
+ echo " ── Checking $CURRENT_LANG ──"
1130
+ case "$CURRENT_LANG" in
1131
+ typescript)
1017
1132
  if ! require_tool "jscpd" "Gate 2" "npm install -D jscpd"; then
1018
1133
  exit 1
1019
1134
  fi
@@ -1026,8 +1141,9 @@ elif [ "$PROJECT_LANG" = "typescript" ]; then
1026
1141
  echo " Consider refactoring duplicate code blocks."
1027
1142
  fi
1028
1143
  echo "✅ PASSED - jscpd duplicate code check completed."
1029
-
1030
- elif [ "$PROJECT_LANG" = "python" ]; then
1144
+ ;;
1145
+
1146
+ python)
1031
1147
  if command -v pylint &> /dev/null; then
1032
1148
  echo "Running pylint duplicate detection..."
1033
1149
  pylint --disable=all --enable=duplicate-code $(echo "$CHANGED_FILES" | grep "\.py$")
@@ -1041,8 +1157,9 @@ elif [ "$PROJECT_LANG" = "python" ]; then
1041
1157
  echo "ℹ️ pylint not found — skipping Python duplicate code check."
1042
1158
  echo " Install pylint: pip install pylint"
1043
1159
  fi
1044
-
1045
- elif [ "$PROJECT_LANG" = "go" ]; then
1160
+ ;;
1161
+
1162
+ go)
1046
1163
  if ! require_tool "jscpd" "Gate 2" "npm install -D jscpd"; then
1047
1164
  exit 1
1048
1165
  fi
@@ -1053,16 +1170,19 @@ elif [ "$PROJECT_LANG" = "go" ]; then
1053
1170
  jscpd $FILES_TO_CHECK 2>&1 | head -10
1054
1171
  fi
1055
1172
  echo "✅ PASSED - Go duplicate code check completed."
1056
-
1057
- elif [ "$PROJECT_LANG" = "powershell" ]; then
1173
+ ;;
1174
+
1175
+ powershell)
1058
1176
  echo "ℹ️ No PowerShell-native duplicate detector (jscpd does not support .ps1)"
1059
1177
  echo "⏭️ SKIPPED - Duplicate code (no tool for PowerShell)"
1060
-
1061
- elif [ "$PROJECT_LANG" = "shell" ]; then
1178
+ ;;
1179
+
1180
+ shell)
1062
1181
  echo "ℹ️ Duplicate detection not typically used for shell scripts"
1063
1182
  echo "⏭️ SKIPPED - Duplicate code (no tool for shell scripts)"
1064
-
1065
- else
1183
+ ;;
1184
+
1185
+ *)
1066
1186
  SOURCE_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx|py|go|java|scala|kt|cpp|c|h|rs|php|dart|swift)$')
1067
1187
  if [ -n "$SOURCE_FILES" ]; then
1068
1188
  if ! require_tool "jscpd" "Gate 2" "npm install -D jscpd"; then
@@ -1074,6 +1194,9 @@ else
1074
1194
  else
1075
1195
  echo "✅ PASSED - No source files detected for duplicate checking."
1076
1196
  fi
1197
+ ;;
1198
+ esac
1199
+ done
1077
1200
  fi
1078
1201
  GATE_2_STATUS="PASS"
1079
1202
  record_gate_audit "gate-2" "duplicate-code" "$GATE_2_STATUS" "0" "$GATE_2_START"
@@ -1093,6 +1216,11 @@ source "$GATE_DIR/gate-4.sh"
1093
1216
  # ============================================================================
1094
1217
  # GATE 5: Tests & Coverage Combined (Combines Old Gates 3 - Tests and 4 - Coverage)
1095
1218
  # Uses adapter system to run tests + coverage for language
1219
+ #
1220
+ # v0.14.3+ (Issue #312): Skip test run when no code files changed.
1221
+ # Code files = src/**/*.ts, plugins/**/*.ts, *.ts, *.tsx, etc. (language-aware).
1222
+ # VERSION, CHANGELOG, docs, config-only changes → SKIP test run entirely.
1223
+ # Gate 5a (new file pairing) and Gate 5b (mock density) still run — they're lightweight.
1096
1224
  # ============================================================================
1097
1225
  2>&1 echo ""
1098
1226
  2>&1 echo "→ Gate 5: Tests & coverage..."
@@ -1397,6 +1525,24 @@ else
1397
1525
  done
1398
1526
  fi
1399
1527
 
1528
+ # ============================================================================
1529
+ # v0.14.3+ (Issue #312): Early skip when no code files changed.
1530
+ # If the commit only touches VERSION, CHANGELOG, docs, config, .gitignore,
1531
+ # or similar non-code files, skip the test run entirely.
1532
+ # Gate 5a (new file pairing) and 5b (mock density) are cheap and still run.
1533
+ # ============================================================================
1534
+ CODE_EXTENSIONS="\.(ts|tsx|js|jsx|py|go|java|kt|kts|cpp|cc|cxx|c|swift|m|mm|dart|sh|ps1)$"
1535
+ CHANGED_CODE_FILES=$(echo "$CHANGED_FILES" | grep -E "$CODE_EXTENSIONS" || true)
1536
+ if [ -z "$CHANGED_CODE_FILES" ]; then
1537
+ echo "✅ PASSED - No code files changed (only docs/config/VERSION), skipping test run."
1538
+ echo " Changed files: $(echo "$CHANGED_FILES" | tr '\n' ' ')"
1539
+ TESTS_EXIT_CODE=0
1540
+ TESTS_SKIPPED=true
1541
+ GATE_5_END=$(gate_start_ms)
1542
+ GATE_5_ELAPSED=$((GATE_5_END - GATE_5_START))
1543
+ echo " ⏱️ Gate 5: ${GATE_5_ELAPSED}ms (skipped — no code changes)"
1544
+ else
1545
+
1400
1546
  # ============================================================================
1401
1547
  # Smart test selection (Issue #286): only run tests affected by changed files.
1402
1548
  # Reuses CHANGED_TEST_FILES already computed in Gate 5b (line 1244).
@@ -1405,11 +1551,12 @@ else
1405
1551
  echo "Running tests..."
1406
1552
 
1407
1553
  # Route to appropriate test runner via adapter
1408
- ADAPTER_FILE=$(resolve_adapter_path "$PROJECT_LANG")
1409
- if [ "${PROJECT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
1554
+ for CURRENT_LANG in $PROJECT_LANGS; do
1555
+ ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
1556
+ if [ "${CURRENT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
1410
1557
  if source "$ADAPTER_FILE" 2>/dev/null; then
1411
1558
  # TypeScript: vitest with smart test selection
1412
- if [ "$PROJECT_LANG" = "typescript" ] && [ -f "package.json" ]; then
1559
+ if [ "$CURRENT_LANG" = "typescript" ] && [ -f "package.json" ]; then
1413
1560
  if npx vitest --version >/dev/null 2>&1; then
1414
1561
  # Count changed test files to decide strategy
1415
1562
  CHANGED_TEST_FILE_COUNT=$(echo "$CHANGED_TEST_FILES" | grep -c '.' || echo "0")
@@ -1480,6 +1627,7 @@ else
1480
1627
  done
1481
1628
  if [ -n "$RELATED_TESTS" ]; then
1482
1629
  echo " Found related test(s): $(echo "$RELATED_TESTS" | tr '\n' ' ')"
1630
+ PARTIAL_TEST_RUN=true # Mark as partial run for coverage check
1483
1631
  TESTS_OUTPUT=$(npx vitest run --coverage $RELATED_TESTS 2>&1)
1484
1632
  TESTS_EXIT_CODE=$?
1485
1633
  echo "$TESTS_OUTPUT" | head -60
@@ -1530,23 +1678,31 @@ else
1530
1678
  echo ""
1531
1679
  echo "❌ BLOCKED - Tests FAILED"
1532
1680
  exit 1
1533
- else
1534
- echo "✅ PASSED - Unit tests passed."
1681
+ else
1682
+ echo "✅ PASSED - Unit tests passed."
1535
1683
  fi
1536
1684
  fi
1537
1685
  else
1538
- echo "✅ PASSED - Could not source ${PROJECT_LANG} adapter for tests, assuming none to run."
1686
+ echo "✅ PASSED - Could not source ${CURRENT_LANG} adapter for tests, assuming none to run."
1539
1687
  fi
1540
1688
  else
1541
- echo "✅ PASSED - No ${PROJECT_LANG} adapter found for tests, assuming none to run."
1689
+ echo "✅ PASSED - No ${CURRENT_LANG} adapter found for tests, assuming none to run."
1542
1690
  fi
1543
-
1544
- # Next, run coverage check (skip for TypeScript — already done above)
1691
+ done # end for CURRENT_LANG
1692
+
1693
+ fi # End: if [ -n "$CHANGED_CODE_FILES" ] (Issue #312 early skip)
1694
+
1695
+ # Next, run coverage check (skip if tests were skipped — Issue #312)
1696
+ if [ "${TESTS_SKIPPED:-false}" = "true" ]; then
1697
+ echo "ℹ️ Test run was skipped — skipping coverage check."
1698
+ else
1545
1699
  echo "Running coverage check..."
1546
1700
 
1547
- if [ "${PROJECT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
1701
+ for CURRENT_LANG in $PROJECT_LANGS; do
1702
+ ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
1703
+ if [ "${CURRENT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
1548
1704
  if source "$ADAPTER_FILE" 2>/dev/null; then
1549
- case "$PROJECT_LANG" in
1705
+ case "$CURRENT_LANG" in
1550
1706
  "typescript")
1551
1707
  # Coverage collected during the test section above.
1552
1708
  # Smart selection (Issue #286): only runs changed/related tests with --coverage,
@@ -1601,14 +1757,15 @@ else
1601
1757
  esac
1602
1758
  else
1603
1759
  # Adapter source failed — warn and let Stage 2 attempt file-based enforcement
1604
- echo "⚠️ Could not source ${PROJECT_LANG} adapter from ${ADAPTER_FILE}, will check coverage files directly..."
1760
+ echo "⚠️ Could not source ${CURRENT_LANG} adapter from ${ADAPTER_FILE}, will check coverage files directly..."
1605
1761
  COV_EXIT=0
1606
1762
  fi
1607
1763
  else
1608
1764
  # No adapter available — warn and let Stage 2 attempt file-based enforcement
1609
- echo "⚠️ No adapter found for ${PROJECT_LANG} (searched: ${ADAPTER_DIR}/, ${ADAPTER_DIR}/adapters/, ${PROJECT_GITHOOKS}/adapters/, ${SCRIPT_DIR}/adapters/) — will check coverage files directly..."
1765
+ echo "⚠️ No adapter found for ${CURRENT_LANG} (searched: ${ADAPTER_DIR}/, ${ADAPTER_DIR}/adapters/, ${PROJECT_GITHOOKS}/adapters/, ${SCRIPT_DIR}/adapters/) — will check coverage files directly..."
1610
1766
  COV_EXIT=0
1611
1767
  fi
1768
+ done # end for CURRENT_LANG (Stage 1 coverage)
1612
1769
 
1613
1770
  # ============================================================================
1614
1771
  # Stage 2: UNCONDITIONAL coverage percentage enforcement
@@ -1622,7 +1779,8 @@ else
1622
1779
  # ============================================================================
1623
1780
  COVERAGE_ENFORCED=false
1624
1781
 
1625
- case "$PROJECT_LANG" in
1782
+ for CURRENT_LANG in $PROJECT_LANGS; do
1783
+ case "$CURRENT_LANG" in
1626
1784
  "typescript")
1627
1785
  COVERAGE_ENFORCED=true
1628
1786
  if [ "${TESTS_SKIPPED:-false}" = "true" ]; then
@@ -1637,8 +1795,8 @@ else
1637
1795
  " 2>/dev/null)
1638
1796
  if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
1639
1797
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
1640
- # If only subset of tests ran, warn instead of block
1641
- if [ -n "${CHANGED_TEST_FILE_COUNT:-}" ] && [ "${CHANGED_TEST_FILE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
1798
+ # If only subset of tests ran (partial run or related tests), warn instead of block
1799
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ] || { [ -n "${CHANGED_TEST_FILE_COUNT:-}" ] && [ "${CHANGED_TEST_FILE_COUNT:-0}" -gt 0 ] 2>/dev/null; }; then
1642
1800
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
1643
1801
  echo " Run 'npx vitest run --coverage' for full coverage check."
1644
1802
  else
@@ -1724,12 +1882,12 @@ else
1724
1882
  fi
1725
1883
  if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
1726
1884
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
1727
- echo "❌ BLOCKED - ${PROJECT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
1885
+ echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
1728
1886
  exit 1
1729
1887
  fi
1730
- echo "${PROJECT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
1888
+ echo "${CURRENT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
1731
1889
  else
1732
- echo "⚠️ Could not determine ${PROJECT_LANG} coverage percentage. No standard coverage report files found."
1890
+ echo "⚠️ Could not determine ${CURRENT_LANG} coverage percentage. No standard coverage report files found."
1733
1891
  fi
1734
1892
  ;;
1735
1893
  esac
@@ -1740,15 +1898,18 @@ else
1740
1898
  exit 1
1741
1899
  fi
1742
1900
  if [ "$COVERAGE_ENFORCED" = false ]; then
1743
- echo "ℹ️ Coverage enforcement not applicable for ${PROJECT_LANG}"
1901
+ echo "ℹ️ Coverage enforcement not applicable for ${CURRENT_LANG}"
1744
1902
  fi
1745
1903
  echo "✅ PASSED - Coverage check completed."
1904
+ done # end for CURRENT_LANG (Stage 2 coverage)
1905
+
1906
+ fi # End: if [ "${TESTS_SKIPPED:-false}" != "true" ] (Issue #312 coverage skip)
1746
1907
 
1747
1908
  # ============================================================================
1748
1909
  # Stage 3: Per-file coverage check for NEW files (Issue #103)
1749
1910
  # New files must have ≥80% coverage individually.
1750
1911
  # ============================================================================
1751
- if [ "$PROJECT_LANG" = "typescript" ] && [ -f "coverage/coverage-summary.json" ]; then
1912
+ if has_project_lang "typescript" && [ -f "coverage/coverage-summary.json" ]; then
1752
1913
  NEW_SOURCE_FILES=$(git diff --cached --name-only --diff-filter=A | grep -E '\.(ts|tsx)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -v '\.d\.ts$' || true)
1753
1914
  if [ -n "$NEW_SOURCE_FILES" ]; then
1754
1915
  echo ""
@@ -1802,15 +1963,16 @@ GATE_6_START=$(gate_start_ms)
1802
1963
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
1803
1964
  echo " ✅ Skipped (documentation project)."
1804
1965
 
1805
- elif [ "$PROJECT_LANG" = "powershell" ]; then
1966
+ elif has_project_lang "powershell" 2>/dev/null || [ "$PROJECT_LANG" = "powershell" ]; then
1806
1967
  echo " ℹ️ No PowerShell architecture tooling available"
1807
1968
  echo " ⏭️ SKIPPED - Architecture validation (no tool for PowerShell)"
1808
1969
 
1809
1970
  else
1810
- # Check if architecture config exists
1811
- if [ -f "architecture.yaml" ] || [ -f ".architecturerc" ] || [ -f ".archlint.yaml" ]; then
1971
+ # Check if architecture config exists (use $PROJECT_ROOT — hook may have cd'd to a subdir via ARCH-03)
1972
+ if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ] || [ -f "$PROJECT_ROOT/.archlint.yaml" ]; then
1812
1973
  # Different architecture tools per language
1813
- case "$PROJECT_LANG" in
1974
+ for CURRENT_LANG in $PROJECT_LANGS; do
1975
+ case "$CURRENT_LANG" in
1814
1976
  "typescript")
1815
1977
  # Try local npx install first (devDependencies), fall back to global
1816
1978
  if npx --no-install @archlinter/cli --version >/dev/null 2>&1; then
@@ -1901,13 +2063,14 @@ else
1901
2063
  fi
1902
2064
  ;;
1903
2065
  *)
1904
- echo " ℹ️ Architecture validation not configured for $PROJECT_LANG"
1905
- echo " ⏭️ SKIPPED - Architecture validation (not configured for $PROJECT_LANG)"
2066
+ echo " ℹ️ Architecture validation not configured for $CURRENT_LANG"
2067
+ echo " ⏭️ SKIPPED - Architecture validation (not configured for $CURRENT_LANG)"
1906
2068
  ;;
1907
2069
  esac
2070
+ done # end for CURRENT_LANG (architecture)
1908
2071
  else
1909
2072
  # Architecture config is REQUIRED — BLOCK if missing
1910
- if [ -f ".architecture-skip" ]; then
2073
+ if [ -f "$PROJECT_ROOT/.architecture-skip" ]; then
1911
2074
  echo " ⚠️ Architecture config missing but .architecture-skip present — allowed to skip"
1912
2075
  echo " ⏭️ SKIPPED - Architecture validation (.architecture-skip exemption)"
1913
2076
  else
@@ -2014,8 +2177,13 @@ record_gate_audit "gate-6" "architecture-boy-scout" "$GATE_6_STATUS" "${BS_BLOCK
2014
2177
  # ============================================================================
2015
2178
  # GATE 7: IaC Security Scanning (Terraform, Kubernetes, Docker)
2016
2179
  # Extracted to gate-7.sh for maintainability
2180
+ # v0.14.3+ (Issue #312): Skip if no IaC files changed
2017
2181
  # ============================================================================
2182
+ if any_changed_files_match "\.tf$" "\.tfvars$" "Dockerfile" "\.kube/" "\.ya?ml$"; then
2018
2183
  source "$GATE_DIR/gate-7.sh"
2184
+ else
2185
+ echo "✅ PASSED - No IaC files changed, skipping Gate 7."
2186
+ fi
2019
2187
 
2020
2188
 
2021
2189
  # GATE 8: Secret Scanning (gitleaks)
@@ -2085,9 +2253,14 @@ echo ""
2085
2253
 
2086
2254
  # ============================================================================
2087
2255
  # GATE 10: Semgrep SAST Security Scan
2088
- # Extracted to gate-10.sh for maintainability
2256
+ # Extracted to gate-10.sh for maintainability (sourced as gate-9.sh)
2257
+ # v0.14.3+ (Issue #312): Skip if no code files changed
2089
2258
  # ============================================================================
2259
+ if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh)$"; then
2090
2260
  source "$GATE_DIR/gate-9.sh"
2261
+ else
2262
+ echo "✅ PASSED - No code files changed, skipping SAST scan."
2263
+ fi
2091
2264
  # ============================================================================
2092
2265
 
2093
2266
  # ============================================================================
@@ -2123,6 +2296,21 @@ echo "✅ Gate 11 completed in ${GATE_11_DURATION}s"
2123
2296
  echo ""
2124
2297
  # ============================================================================
2125
2298
 
2299
+ # ============================================================================
2300
+ # GATE 12: File Hygiene Check
2301
+ # Detects trailing whitespace, missing EOF newlines, merge conflict markers,
2302
+ # and oversized files in staged changes.
2303
+ # v0.14.15+ (Issue #350, #351)
2304
+ # ============================================================================
2305
+ if [ -f "$GATE_DIR/gate-12-file-hygiene.sh" ]; then
2306
+ source "$GATE_DIR/gate-12-file-hygiene.sh"
2307
+ elif [ -f "$(git rev-parse --show-toplevel 2>/dev/null)/githooks/gate-12-file-hygiene.sh" ]; then
2308
+ source "$(git rev-parse --show-toplevel)/githooks/gate-12-file-hygiene.sh"
2309
+ else
2310
+ echo "⏭️ SKIPPED - Gate 12: File Hygiene (gate-12-file-hygiene.sh not found)"
2311
+ fi
2312
+ # ============================================================================
2313
+
2126
2314
  generate_quality_report() {
2127
2315
  local HISTORY_FILE=".quality-history.jsonl"
2128
2316
  local COMMIT_HASH
@@ -2181,7 +2369,7 @@ generate_quality_report() {
2181
2369
  "generatedAt": "$TIMESTAMP",
2182
2370
  "branch": "$BRANCH",
2183
2371
  "commit": "$COMMIT_HASH",
2184
- "language": "${PROJECT_LANG:-unknown}",
2372
+ "language": "${PROJECT_LANGS:-${PROJECT_LANG:-unknown}}",
2185
2373
  "overall": {
2186
2374
  "gatesPassed": $PASSED_COUNT,
2187
2375
  "gatesTotal": $TOTAL_GATES,