@boyingliu01/xp-gate 0.20.0 → 0.20.2

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.
Files changed (90) hide show
  1. package/adapters/gate-8.sh +115 -73
  2. package/adapters/java.sh +373 -27
  3. package/adapters/powershell.sh +60 -5
  4. package/adapters/python.sh +38 -4
  5. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  6. package/build-integrity/gate-10.ts +17 -2
  7. package/gate-3.sh +51 -3
  8. package/gate-4.sh +32 -1
  9. package/gate-8.sh +115 -73
  10. package/hooks/gate-3.sh +51 -3
  11. package/hooks/gate-4.sh +32 -1
  12. package/hooks/gate-8.sh +115 -73
  13. package/hooks/pre-commit +404 -62
  14. package/lib/__tests__/doctor-spawn-capability.test.js +116 -0
  15. package/lib/__tests__/doctor-tui-skill-freshness.test.js +49 -10
  16. package/lib/doctor-tui.js +21 -3
  17. package/lib/doctor.js +111 -8
  18. package/lib/gate-audit.ts +3 -0
  19. package/lib/mattpocock-license-verdict.json +1 -1
  20. package/mock-policy/AGENTS.md +4 -4
  21. package/mutation/AGENTS.md +4 -4
  22. package/package.json +2 -2
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/admin-template-guidelines/SKILL.md +1 -0
  25. package/plugins/claude-code/skills/batch-grill-me/SKILL.md +1 -0
  26. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +1 -0
  27. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  28. package/plugins/claude-code/skills/domain-modeling/SKILL.md +1 -0
  29. package/plugins/claude-code/skills/grill-with-docs/SKILL.md +1 -0
  30. package/plugins/claude-code/skills/grilling/SKILL.md +1 -0
  31. package/plugins/claude-code/skills/improve-codebase-architecture/SKILL.md +1 -0
  32. package/plugins/claude-code/skills/ralph-loop/SKILL.md +1 -0
  33. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  34. package/plugins/claude-code/skills/test-driven-development/SKILL.md +1 -0
  35. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  36. package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +1 -0
  37. package/plugins/claude-code/skills/to-issues/SKILL.md +1 -0
  38. package/plugins/dsh/package.json +1 -1
  39. package/plugins/dsh/skills/admin-template-guidelines/SKILL.md +1 -0
  40. package/plugins/dsh/skills/batch-grill-me/SKILL.md +1 -0
  41. package/plugins/dsh/skills/clipboard-vision/SKILL.md +1 -0
  42. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  43. package/plugins/dsh/skills/domain-modeling/SKILL.md +1 -0
  44. package/plugins/dsh/skills/grill-with-docs/SKILL.md +1 -0
  45. package/plugins/dsh/skills/grilling/SKILL.md +1 -0
  46. package/plugins/dsh/skills/improve-codebase-architecture/SKILL.md +1 -0
  47. package/plugins/dsh/skills/ralph-loop/SKILL.md +1 -0
  48. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  49. package/plugins/dsh/skills/test-driven-development/SKILL.md +1 -0
  50. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  51. package/plugins/dsh/skills/test-specification-alignment/SKILL.md +1 -0
  52. package/plugins/dsh/skills/to-issues/SKILL.md +1 -0
  53. package/plugins/opencode/package.json +1 -1
  54. package/plugins/opencode/skills/admin-template-guidelines/SKILL.md +1 -0
  55. package/plugins/opencode/skills/batch-grill-me/SKILL.md +1 -0
  56. package/plugins/opencode/skills/clipboard-vision/SKILL.md +1 -0
  57. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  58. package/plugins/opencode/skills/domain-modeling/SKILL.md +1 -0
  59. package/plugins/opencode/skills/grill-with-docs/SKILL.md +1 -0
  60. package/plugins/opencode/skills/grilling/SKILL.md +1 -0
  61. package/plugins/opencode/skills/improve-codebase-architecture/SKILL.md +1 -0
  62. package/plugins/opencode/skills/ralph-loop/SKILL.md +1 -0
  63. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  64. package/plugins/opencode/skills/test-driven-development/SKILL.md +1 -0
  65. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  66. package/plugins/opencode/skills/test-specification-alignment/SKILL.md +1 -0
  67. package/plugins/opencode/skills/to-issues/SKILL.md +1 -0
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  72. package/principles/AGENTS.md +4 -4
  73. package/principles/adapters/__tests__/java.test.ts +317 -0
  74. package/principles/adapters/java.ts +325 -13
  75. package/principles/boy-scout.ts +36 -6
  76. package/scripts/sync-package-content.js +13 -6
  77. package/skills/admin-template-guidelines/SKILL.md +1 -0
  78. package/skills/batch-grill-me/SKILL.md +1 -0
  79. package/skills/clipboard-vision/SKILL.md +1 -0
  80. package/skills/delphi-review/AGENTS.md +4 -4
  81. package/skills/domain-modeling/SKILL.md +1 -0
  82. package/skills/grill-with-docs/SKILL.md +1 -0
  83. package/skills/grilling/SKILL.md +1 -0
  84. package/skills/improve-codebase-architecture/SKILL.md +1 -0
  85. package/skills/ralph-loop/SKILL.md +1 -0
  86. package/skills/sprint-flow/AGENTS.md +4 -4
  87. package/skills/test-driven-development/SKILL.md +1 -0
  88. package/skills/test-specification-alignment/AGENTS.md +4 -4
  89. package/skills/test-specification-alignment/SKILL.md +1 -0
  90. package/skills/to-issues/SKILL.md +1 -0
package/hooks/pre-commit CHANGED
@@ -242,6 +242,9 @@ record_gate_audit() {
242
242
  local passed="$3"
243
243
  local issues_found="$4"
244
244
  local start_ms="$5"
245
+ # Optional free-form detail (e.g. gate-3's effective CCN threshold, #507).
246
+ # Additive and omitted when absent, so existing callers stay unchanged.
247
+ local detail="${6:-}"
245
248
  local end_ms
246
249
  end_ms=$(now_ms)
247
250
  local duration_ms=$((end_ms - start_ms))
@@ -271,6 +274,7 @@ record_gate_audit() {
271
274
  --duration-ms "$duration_ms" \
272
275
  $duration_anomaly_flag \
273
276
  --trigger commit \
277
+ ${detail:+--detail "$detail"} \
274
278
  2>/dev/null
275
279
  } &
276
280
  disown 2>/dev/null || true
@@ -459,10 +463,36 @@ record_gate_audit "gate-0" "version-consistency" "$GATE_0_STATUS" "0" "$GATE_0_S
459
463
 
460
464
  echo ""
461
465
 
462
- # Get list of changed files
463
- CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
464
-
465
- if [ -z "$CHANGED_FILES" ]; then
466
+ # Get list of changed files.
467
+ #
468
+ # Byte-identical mirrors (src/npm-package/**) are governed by CI Mirror Parity,
469
+ # not by re-linting. Leaving them in this scan set double-counted every
470
+ # lint/complexity signal and made jscpd report the source-vs-mirror pair as a
471
+ # 100% whole-file clone, which then tripped Gate 6's "<=5 warnings must clear
472
+ # to zero" rule on ANY edit to a mirrored file -- a warning that is
473
+ # structurally impossible to clear. (Gate 6's MIRROR_EXCLUDE and #494's test
474
+ # selection already exclude mirrors for the same reason.)
475
+ #
476
+ # IMPORTANT: the exclusion applies to the LINT SCAN CORPUS only. The early
477
+ # exit below must key on the RAW staged list -- Gate 8 (secret scanning) is
478
+ # unconditional and fail-closed (#499), and gating it behind a path-filtered
479
+ # list would let a mirror-only commit land with zero secret scanning and no
480
+ # CI backstop.
481
+ # Single source of truth for the mirror-path filter (#510): every consumer
482
+ # that must ignore the byte-identical npm-package mirrors (lint corpus here,
483
+ # Boy Scout's new/modified split further down) reads this one regex, so the
484
+ # two sites cannot drift apart.
485
+ # dashboard/ is a standalone browser-only visualization page (document/Chart.js
486
+ # globals, static HTML output). It is not part of the gate engine, never runs
487
+ # in CI or hooks, and its chart-config object literals trip structural rules
488
+ # (deep-nesting/long-function) that carry no signal for this codebase. Same
489
+ # scope rationale as the mirror exclusion (#507 Delphi round-2 follow-up).
490
+ MIRROR_PATH_FILTER='^src/npm-package/'
491
+ DASHBOARD_PATH_FILTER='^dashboard/'
492
+ RAW_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
493
+ CHANGED_FILES=$(printf '%s\n' "$RAW_CHANGED_FILES" | grep -v "$MIRROR_PATH_FILTER" | grep -v "$DASHBOARD_PATH_FILTER" || true)
494
+
495
+ if [ -z "$RAW_CHANGED_FILES" ]; then
466
496
  echo "No files changed. Skipping gates."
467
497
  exit 0
468
498
  fi
@@ -629,21 +659,21 @@ if [ -z "$PROJECT_LANGS" ] && [ -z "$_CONFIG_LANGS" ]; then
629
659
  PROJECT_LANGS="dart"
630
660
  fi
631
661
  elif [ -f "pom.xml" ] || [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
632
- KOTLIN_FILES=$(find . -name "*.kt" -not -path "./.git/*" 2>/dev/null | wc -l)
662
+ KOTLIN_FILES=$(find . -name "*.kt" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | wc -l)
633
663
  if [ "$KOTLIN_FILES" -gt 0 ]; then
634
664
  PROJECT_LANGS="kotlin"
635
665
  else
636
666
  PROJECT_LANGS="java"
637
667
  fi
638
668
  elif [ -f "CMakeLists.txt" ] || [ -f "Makefile" ]; then
639
- CPP_FILES=$(find . -maxdepth 2 \( -name "*.cpp" -o -name "*.cxx" -o -name "*.cc" \) -not -path "./.git/*" 2>/dev/null | head -1)
640
- OBJC_FILES=$(find . -maxdepth 2 \( -name "*.m" -o -name "*.mm" \) -not -path "./.git/*" 2>/dev/null | head -1)
669
+ CPP_FILES=$(find . -maxdepth 2 \( -name "*.cpp" -o -name "*.cxx" -o -name "*.cc" \) -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
670
+ OBJC_FILES=$(find . -maxdepth 2 \( -name "*.m" -o -name "*.mm" \) -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
641
671
  if [ -n "$CPP_FILES" ]; then
642
672
  PROJECT_LANGS="cpp"
643
673
  elif [ -n "$OBJC_FILES" ]; then
644
674
  PROJECT_LANGS="objectivec"
645
675
  fi
646
- elif [ -n "$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" 2>/dev/null | head -1)" ]; then
676
+ elif [ -n "$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)" ]; then
647
677
  PROJECT_LANGS="powershell"
648
678
  fi
649
679
  fi
@@ -711,7 +741,7 @@ CHANGEDFILELIST
711
741
  # Only used when extension-based detection found nothing
712
742
  if [ -z "$PROJECT_LANGS" ]; then
713
743
  # Search for TypeScript/JavaScript projects
714
- TSCONFIG_SUB=$(find . -maxdepth 2 -name "tsconfig.json" -not -path "./.git/*" -not -path "./node_modules/*" 2>/dev/null | head -1)
744
+ TSCONFIG_SUB=$(find . -maxdepth 2 -name "tsconfig.json" -not -path "./.git/*" -not -path "*/node_modules/*" -not -path "./node_modules/*" 2>/dev/null | head -1)
715
745
  if [ -n "$TSCONFIG_SUB" ]; then
716
746
  PROJECT_SUBDIR=$(dirname "$TSCONFIG_SUB")
717
747
  if [ -f "$PROJECT_SUBDIR/package.json" ] && grep -q "react-native" "$PROJECT_SUBDIR/package.json" 2>/dev/null; then
@@ -723,7 +753,7 @@ CHANGEDFILELIST
723
753
 
724
754
  # Search for Python projects
725
755
  if [ -z "$PROJECT_LANGS" ]; then
726
- PYPROJECT_SUB=$(find . -maxdepth 2 -name "pyproject.toml" -not -path "./.git/*" 2>/dev/null | head -1)
756
+ PYPROJECT_SUB=$(find . -maxdepth 2 -name "pyproject.toml" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
727
757
  if [ -n "$PYPROJECT_SUB" ]; then
728
758
  PROJECT_LANGS="python"
729
759
  PROJECT_SUBDIR=$(dirname "$PYPROJECT_SUB")
@@ -732,7 +762,7 @@ CHANGEDFILELIST
732
762
 
733
763
  # Search for Go projects
734
764
  if [ -z "$PROJECT_LANGS" ]; then
735
- GOMOD_SUB=$(find . -maxdepth 2 -name "go.mod" -not -path "./.git/*" 2>/dev/null | head -1)
765
+ GOMOD_SUB=$(find . -maxdepth 2 -name "go.mod" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
736
766
  if [ -n "$GOMOD_SUB" ]; then
737
767
  PROJECT_LANGS="go"
738
768
  PROJECT_SUBDIR=$(dirname "$GOMOD_SUB")
@@ -741,7 +771,7 @@ CHANGEDFILELIST
741
771
 
742
772
  # Search for Flutter/Dart projects
743
773
  if [ -z "$PROJECT_LANGS" ]; then
744
- PUBSPEC_SUB=$(find . -maxdepth 2 -name "pubspec.yaml" -not -path "./.git/*" 2>/dev/null | head -1)
774
+ PUBSPEC_SUB=$(find . -maxdepth 2 -name "pubspec.yaml" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
745
775
  if [ -n "$PUBSPEC_SUB" ]; then
746
776
  if grep -q "flutter:" "$PUBSPEC_SUB" 2>/dev/null || [ -f "$(dirname "$PUBSPEC_SUB")/.metadata" ]; then
747
777
  PROJECT_LANGS="flutter"
@@ -754,8 +784,8 @@ CHANGEDFILELIST
754
784
 
755
785
  # Search for Java/Kotlin projects
756
786
  if [ -z "$PROJECT_LANGS" ]; then
757
- POM_SUB=$(find . -maxdepth 2 -name "pom.xml" -not -path "./.git/*" 2>/dev/null | head -1)
758
- GRADLE_SUB=$(find . -maxdepth 2 -name "build.gradle" -not -path "./.git/*" 2>/dev/null | head -1)
787
+ POM_SUB=$(find . -maxdepth 2 -name "pom.xml" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
788
+ GRADLE_SUB=$(find . -maxdepth 2 -name "build.gradle" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
759
789
  if [ -n "$POM_SUB" ] || [ -n "$GRADLE_SUB" ]; then
760
790
  MANIFEST_FILE="${POM_SUB:-$GRADLE_SUB}"
761
791
  PROJECT_SUBDIR=$(dirname "$MANIFEST_FILE")
@@ -770,7 +800,7 @@ CHANGEDFILELIST
770
800
 
771
801
  # Search for C++ projects
772
802
  if [ -z "$PROJECT_LANGS" ]; then
773
- CMAKE_SUB=$(find . -maxdepth 2 -name "CMakeLists.txt" -not -path "./.git/*" 2>/dev/null | head -1)
803
+ CMAKE_SUB=$(find . -maxdepth 2 -name "CMakeLists.txt" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
774
804
  if [ -n "$CMAKE_SUB" ]; then
775
805
  PROJECT_LANGS="cpp"
776
806
  PROJECT_SUBDIR=$(dirname "$CMAKE_SUB")
@@ -779,7 +809,7 @@ CHANGEDFILELIST
779
809
 
780
810
  # Search for React Native projects (ios/ + android/ directories)
781
811
  if [ -z "$PROJECT_LANGS" ]; then
782
- RN_SUB=$(find . -maxdepth 2 -type d -name "ios" -not -path "./.git/*" 2>/dev/null | head -1)
812
+ RN_SUB=$(find . -maxdepth 2 -type d -name "ios" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
783
813
  if [ -n "$RN_SUB" ]; then
784
814
  PROJECT_SUBDIR=$(dirname "$RN_SUB")
785
815
  if [ -d "$PROJECT_SUBDIR/android" ] || [ -f "$PROJECT_SUBDIR/package.json" ]; then
@@ -790,7 +820,7 @@ CHANGEDFILELIST
790
820
 
791
821
  # Search for PowerShell projects
792
822
  if [ -z "$PROJECT_LANGS" ]; then
793
- PS_SUB=$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" 2>/dev/null | head -1)
823
+ PS_SUB=$(find . -maxdepth 2 -name "*.ps1" -not -path "./.git/*" -not -path "*/node_modules/*" 2>/dev/null | head -1)
794
824
  if [ -n "$PS_SUB" ]; then
795
825
  PROJECT_LANGS="powershell"
796
826
  PROJECT_SUBDIR=$(dirname "$PS_SUB")
@@ -815,7 +845,7 @@ CHANGEDFILELIST
815
845
 
816
846
  # ---- Documentation-only fallback ----
817
847
  if [ -z "$PROJECT_LANGS" ]; then
818
- 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)
848
+ 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/*" -not -path "*/node_modules/*" 2>/dev/null | wc -l)
819
849
 
820
850
  if [ "$CODE_FILES" -eq 0 ]; then
821
851
  PROJECT_LANGS="documentation-only"
@@ -857,11 +887,21 @@ has_project_lang() {
857
887
  }
858
888
  # Switch to subdirectory if detected (for single-language subdirectory projects).
859
889
  # Multi-language projects stay at root — each adapter handles its own path.
890
+ #
891
+ # IN_PROJECT_SUBDIR records whether this cd actually happened. Git reports
892
+ # staged paths relative to the repo root; after cd "$PROJECT_SUBDIR" the [ -f ]
893
+ # probes below need subdirectory-relative paths (hence the PROJECT_SUBDIR
894
+ # prefix strips in Gates 5a/5b/5c), but when the hook stays at the root
895
+ # (multi-language projects) stripping the prefix makes every path-based check
896
+ # resolve against the wrong directory — false BLOCKs in Gate 5a, silent no-ops
897
+ # in 5b/5c. Only strip when we really are inside the subdirectory.
860
898
  ORIGINAL_DIR=""
899
+ IN_PROJECT_SUBDIR=0
861
900
  _lang_count=$(echo "$PROJECT_LANGS" | wc -w)
862
901
  if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
863
902
  ORIGINAL_DIR=$(pwd)
864
903
  cd "$PROJECT_SUBDIR"
904
+ IN_PROJECT_SUBDIR=1
865
905
  echo "Working in: $PROJECT_SUBDIR/"
866
906
  echo ""
867
907
  fi
@@ -891,20 +931,67 @@ else
891
931
  # in githooks/lib/typecheck.sh so a project's own checker (vue-tsc, svelte-check,
892
932
  # a `typecheck` script) is used instead of a hardcoded tsc that cannot resolve
893
933
  # single-file components (#436).
894
- source "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh"
895
- run_typescript_typecheck
896
- TS_CHECK_EXIT=$?
934
+ #
935
+ # Resolve the TS project directory first (#470). The probes below used to run
936
+ # against the repo root only; in multi-language monorepos (e.g. web/ +
937
+ # backend/, where the hook stays at the root — see the cd block above)
938
+ # tsconfig.json / biome.json live in the language subdirectory, so the whole
939
+ # TS half of Gate 1 silently SKIPped ("tsconfig.json not found") while a
940
+ # project config existed. Order: repo root first (unchanged behaviour for
941
+ # plain projects), then the detected language subdirectory, then a shallow
942
+ # search.
943
+ TS_PROJECT_DIR=""
944
+ for _ts_dir in "." "${PROJECT_SUBDIR:-}"; do
945
+ [ -z "$_ts_dir" ] && continue
946
+ if [ -f "$_ts_dir/tsconfig.json" ] || [ -f "$_ts_dir/biome.json" ] || [ -f "$_ts_dir/biome.jsonc" ] \
947
+ || [ -f "$_ts_dir/eslint.config.js" ] || [ -f "$_ts_dir/.eslintrc.json" ] \
948
+ || [ -f "$_ts_dir/.eslintrc.js" ] || [ -f "$_ts_dir/.eslintrc.cjs" ]; then
949
+ TS_PROJECT_DIR="$_ts_dir"
950
+ break
951
+ fi
952
+ done
953
+ if [ -z "$TS_PROJECT_DIR" ]; then
954
+ _TSCONFIG_CAND=$(find . -maxdepth 2 \( -name tsconfig.json -o -name biome.json -o -name biome.jsonc -o -name eslint.config.js -o -name .eslintrc.json -o -name .eslintrc.js -o -name .eslintrc.cjs \) -not -path './.git/*' -not -path './node_modules/*' 2>/dev/null | head -1)
955
+ if [ -n "$_TSCONFIG_CAND" ]; then
956
+ TS_PROJECT_DIR=$(dirname "$_TSCONFIG_CAND")
957
+ fi
958
+ fi
959
+ [ -z "$TS_PROJECT_DIR" ] && TS_PROJECT_DIR="."
960
+ if [ "$TS_PROJECT_DIR" != "." ]; then
961
+ echo " TypeScript project directory: $TS_PROJECT_DIR/"
962
+ # typecheck.sh is cwd-relative: run it from the TS project subdirectory in a
963
+ # subshell so the language loop's next iteration still starts at the root.
964
+ # Exit contract: 0 clean, 1 findings (block), 2 checker unavailable (skip).
965
+ # A cd/source infrastructure failure must NOT read as "type errors": map it
966
+ # to 2 (skip) so 1 stays reserved for real findings (#508 walkthrough).
967
+ ( cd "$TS_PROJECT_DIR" 2>/dev/null || exit 2 \
968
+ ; [ -r "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh" ] || exit 2 \
969
+ ; source "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh" && run_typescript_typecheck )
970
+ TS_CHECK_EXIT=$?
971
+ else
972
+ source "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh"
973
+ run_typescript_typecheck
974
+ TS_CHECK_EXIT=$?
975
+ fi
897
976
  if [ "$TS_CHECK_EXIT" -eq 1 ]; then
898
977
  exit 1
899
978
  fi
900
979
 
901
- # Run ESLint linting if config exists
902
- if [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ]; then
980
+ # Run ESLint linting if config exists (root or TS project subdirectory)
981
+ if [ -f "$TS_PROJECT_DIR/.eslintrc.json" ] || [ -f "$TS_PROJECT_DIR/.eslintrc.js" ] || [ -f "$TS_PROJECT_DIR/.eslintrc.cjs" ] || [ -f "$TS_PROJECT_DIR/eslint.config.js" ]; then
903
982
  echo "Running ESLint linting..."
904
983
  # Filter to only TS/JS files - ESLint config ignores non-matching files
905
984
  ESLINT_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx)$' || true)
906
985
  if [ -n "$ESLINT_FILES" ]; then
907
- ESLINT_OUTPUT=$(npx eslint $ESLINT_FILES -f json --no-warn-ignored 2>&1)
986
+ # File paths from git are repo-root relative, so they are correct from
987
+ # the root. Legacy .eslintrc.* configs are discovered by walking up from
988
+ # each linted file (found in the subdirectory automatically); flat
989
+ # configs are discovered from the CWD, so point at them explicitly.
990
+ if [ "$TS_PROJECT_DIR" != "." ] && [ -f "$TS_PROJECT_DIR/eslint.config.js" ]; then
991
+ ESLINT_OUTPUT=$(npx eslint --config "$TS_PROJECT_DIR/eslint.config.js" $ESLINT_FILES -f json --no-warn-ignored 2>&1)
992
+ else
993
+ ESLINT_OUTPUT=$(npx eslint $ESLINT_FILES -f json --no-warn-ignored 2>&1)
994
+ fi
908
995
  ESLINT_EXIT=$?
909
996
  if [ "$ESLINT_EXIT" -ne 0 ]; then
910
997
  if [ -f ".xp-gate/lint-baseline.json" ] && command -v node &> /dev/null; then
@@ -951,8 +1038,8 @@ else
951
1038
  fi
952
1039
  else
953
1040
  echo "ℹ️ No ESLint configuration found - Skipping"
954
- if [ -f "package.json" ] && command -v node &> /dev/null; then
955
- HAS_ESLINT=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d.eslint||deps.eslint)?'yes':'no')}catch{console.log('no')}") && true
1041
+ if [ -f "$TS_PROJECT_DIR/package.json" ] && command -v node &> /dev/null; then
1042
+ HAS_ESLINT=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('$TS_PROJECT_DIR/package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d.eslint||deps.eslint)?'yes':'no')}catch{console.log('no')}") && true
956
1043
  if [ "$HAS_ESLINT" = "yes" ]; then
957
1044
  echo "⚠️ WARNING: eslint is installed in package.json but no eslint config found"
958
1045
  echo " Either create .eslintrc.* / eslint.config.* or remove eslint from dependencies"
@@ -962,9 +1049,17 @@ else
962
1049
  fi
963
1050
 
964
1051
  # ── Biome lint/format check ──
965
- if [ -f "biome.json" ] || [ -f "biome.jsonc" ]; then
1052
+ # Default the project dir so this branch stays runnable standalone
1053
+ # (the gate-1-biome-noop suite extracts it verbatim); a no-op inside the
1054
+ # hook, where TS_PROJECT_DIR is resolved above.
1055
+ TS_PROJECT_DIR="${TS_PROJECT_DIR:-.}"
1056
+ if [ -f "$TS_PROJECT_DIR/biome.json" ] || [ -f "$TS_PROJECT_DIR/biome.jsonc" ]; then
966
1057
  echo "Running Biome lint/format check..."
967
- BIOME_OUT=$(npx biome check --staged . 2>&1)
1058
+ # Run from the TS project directory so Biome auto-discovers its config and
1059
+ # scopes --staged to that subdirectory (mirrors the single-language subdir
1060
+ # mode, which executes after cd "$PROJECT_SUBDIR"). Empirically verified:
1061
+ # `biome check --staged .` from the subdir checks the staged files under it.
1062
+ BIOME_OUT=$(cd "$TS_PROJECT_DIR" && npx biome check --staged . 2>&1)
968
1063
  BIOME_STATUS=$?
969
1064
  if [ "$BIOME_STATUS" -ne 0 ]; then
970
1065
  # Biome exits 1 with `internalError/io - No files were processed` when the
@@ -989,8 +1084,8 @@ else
989
1084
  echo "✅ PASSED - Biome check."
990
1085
  fi
991
1086
  else
992
- if [ -f "package.json" ] && command -v node &> /dev/null; then
993
- HAS_BIOME=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d['@biomejs/biome']||deps['@biomejs/biome'])?'yes':'no')}catch{console.log('no')}") && true
1087
+ if [ -f "$TS_PROJECT_DIR/package.json" ] && command -v node &> /dev/null; then
1088
+ HAS_BIOME=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('$TS_PROJECT_DIR/package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d['@biomejs/biome']||deps['@biomejs/biome'])?'yes':'no')}catch{console.log('no')}") && true
994
1089
  if [ "$HAS_BIOME" = "yes" ]; then
995
1090
  echo ""
996
1091
  echo "⚠️ WARNING: @biomejs/biome is installed in package.json but no biome.json found"
@@ -1252,7 +1347,66 @@ else
1252
1347
  echo "✅ PASSED - Basic shell script syntax check."
1253
1348
  fi
1254
1349
  ;;
1255
-
1350
+
1351
+ java)
1352
+ # GATE 1 Java: wc-java-lint with fail-closed verdicts (#507 S1, DR-001).
1353
+ # java.sh run_static_analysis verdict codes:
1354
+ # 0=PASS 1=FAIL(violations) 2=BLOCK(execution error) 3=WARN(non-blocking).
1355
+ # Every non-0/1/2 outcome is scored as WARN -- IN the denominator, so it
1356
+ # forbids an overall PASS. Delphi round-2 A-MAJOR-1: these paths used to
1357
+ # map to SKIP, which sits OUT of the denominator, letting a repo with no
1358
+ # Java engine (or findings suppressed in report mode) report PASS/10.0
1359
+ # while its Java quality was never verified.
1360
+ # The adapter applies the XP_GATE_WC_JAVA_LINT=soft escape valve itself, so
1361
+ # a 2 reaching this branch is hard: BLOCK the commit. Captured to a var
1362
+ # (not piped) so the verdict code survives regardless of output length --
1363
+ # `cmd | head` makes a long report die with SIGPIPE, which would turn a
1364
+ # real verdict into an accident of line count.
1365
+ ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
1366
+ if [ -z "$ADAPTER_FILE" ] || ! source "$ADAPTER_FILE" 2>/dev/null; then
1367
+ echo "⚠️ WARN - Java adapter not found (code quality NOT verified)"
1368
+ GATE_1_STATUS="WARN"
1369
+ GATE_1_DETAIL="java: adapter missing"
1370
+ else
1371
+ JAVA_G1_EXIT=0
1372
+ # `|| JAVA_G1_EXIT=$?` (not a bare assignment): under a set -e consumer a
1373
+ # non-zero verdict would abort the host hook before the mapping below.
1374
+ JAVA_G1_OUTPUT=$(run_static_analysis 2>&1) || JAVA_G1_EXIT=$?
1375
+ # SKIP/WARN reasons cross the command-substitution subshell as an
1376
+ # `XP_G1_REASON: ...` stdout line -- an `export` inside the adapter can
1377
+ # NEVER reach this process (#507 Delphi round-1 B-MAJOR-1: verified the
1378
+ # subshell discards the child env). Parse, then strip from display.
1379
+ JAVA_G1_REASON=$(printf '%s\n' "$JAVA_G1_OUTPUT" | sed -n 's/^XP_G1_REASON: //p' | head -1)
1380
+ if [ -n "$JAVA_G1_REASON" ]; then
1381
+ JAVA_G1_OUTPUT=$(printf '%s\n' "$JAVA_G1_OUTPUT" | grep -v '^XP_G1_REASON: ')
1382
+ fi
1383
+ [ -n "$JAVA_G1_OUTPUT" ] && echo "$JAVA_G1_OUTPUT" | head -40
1384
+ case "$JAVA_G1_EXIT" in
1385
+ 0)
1386
+ echo "✅ PASSED - Java code quality (wc-java-lint)."
1387
+ ;;
1388
+ 1)
1389
+ echo "❌ BLOCKED - Java static analysis violations (fix or add suppressions)"
1390
+ exit 1
1391
+ ;;
1392
+ 2)
1393
+ echo "❌ BLOCKED - wc-java-lint unusable (fail-closed; unset XP_GATE_WC_JAVA_LINT=soft only if you accept the risk)"
1394
+ exit 1
1395
+ ;;
1396
+ 3)
1397
+ echo "⚠️ WARN - Java code quality NOT verified (engine unavailable or downgraded)"
1398
+ GATE_1_STATUS="WARN"
1399
+ GATE_1_DETAIL="java: ${JAVA_G1_REASON:-unavailable (warn)}"
1400
+ ;;
1401
+ *)
1402
+ echo "⚠️ WARN - Java code quality NOT verified (unexpected verdict $JAVA_G1_EXIT)"
1403
+ GATE_1_STATUS="WARN"
1404
+ GATE_1_DETAIL="java: unexpected verdict $JAVA_G1_EXIT"
1405
+ ;;
1406
+ esac
1407
+ fi
1408
+ ;;
1409
+
1256
1410
  *)
1257
1411
  # Route to appropriate adapter for other languages
1258
1412
  ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
@@ -1287,9 +1441,13 @@ else
1287
1441
  done
1288
1442
  fi
1289
1443
  fi # End: if ! any_changed_files_match (Issue #312 skip)
1290
- GATE_1_STATUS="PASS"
1444
+ GATE_1_STATUS="${GATE_1_STATUS:-PASS}"
1291
1445
  GATE_1_TOOL="${PROJECT_LANGS}"
1292
- record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START"
1446
+ # The java branch may set GATE_1_STATUS=WARN + a GATE_1_DETAIL warn reason
1447
+ # (engine unavailable / soft or report downgrade); route it into the audit
1448
+ # trail via the S2 detail plumbing so a WARN verdict is diagnosable, not
1449
+ # silent. WARN stays IN the scoring denominator (Delphi round-2 A-MAJOR-1).
1450
+ record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START" "${GATE_1_DETAIL:-}"
1293
1451
 
1294
1452
  # ============================================================================
1295
1453
  # GATE 2: Duplicate Code Detection (NEW gate)
@@ -1480,11 +1638,15 @@ else
1480
1638
  # --- BLOCK path: New .ts/.tsx files without corresponding tests ---
1481
1639
  NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -v 'node_modules/' | grep -v '\.next/' | grep -v '\.nuxt/' | grep -v 'dist/' | grep -v 'build/' | grep -v '.turbo/' | grep -v '.cache/' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
1482
1640
 
1483
- # Strip PROJECT_SUBDIR prefix from new-TS-file paths when running in a subdirectory.
1484
- # git diff returns repo-root relative paths, but after cd "$PROJECT_SUBDIR"
1485
- # the test-pairing [ -f ] checks below need subdirectory-relative paths.
1641
+ # Strip PROJECT_SUBDIR prefix from new-TS-file paths ONLY when the hook
1642
+ # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1; single-language
1643
+ # subdirectory projects). git diff returns repo-root relative paths; after
1644
+ # cd "$PROJECT_SUBDIR" the test-pairing [ -f ] checks below need
1645
+ # subdirectory-relative paths. When the hook stays at the repo root
1646
+ # (multi-language projects) the paths must KEEP their prefix or every
1647
+ # [ -f ] lookup misses and paired files are falsely BLOCKED.
1486
1648
  # (Mirrors the v0.14.31+ Gate 5b fix at lines 1603-1608; Gate 5a was missing it.)
1487
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$NEW_TS_FILES" ]; then
1649
+ if [ "$IN_PROJECT_SUBDIR" = "1" ] && [ -n "$NEW_TS_FILES" ]; then
1488
1650
  NEW_TS_FILES=$(echo "$NEW_TS_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1489
1651
  fi
1490
1652
 
@@ -1695,10 +1857,13 @@ else
1695
1857
  # each plugin directory).
1696
1858
  CHANGED_TEST_FILES=$(echo "$CHANGED_TEST_FILES" | grep -vE '^plugins/[^/]+/' || true)
1697
1859
 
1698
- # Strip PROJECT_SUBDIR prefix from test file paths when running in subdirectory.
1699
- # git diff returns repo-root relative paths, but after cd "$PROJECT_SUBDIR" (line 728)
1700
- # vitest and [ -f ] checks need subdirectory-relative paths. (v0.14.31+ fix)
1701
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$CHANGED_TEST_FILES" ]; then
1860
+ # Strip PROJECT_SUBDIR prefix from test file paths ONLY when the hook
1861
+ # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1). git diff returns
1862
+ # repo-root relative paths; after cd "$PROJECT_SUBDIR" vitest and [ -f ]
1863
+ # checks need subdirectory-relative paths. At the repo root (multi-language
1864
+ # projects) stripping would silently skip the mock-density scan and feed
1865
+ # vitest paths that do not exist. (v0.14.31+ fix, gated on the cd in v0.19.x)
1866
+ if [ "$IN_PROJECT_SUBDIR" = "1" ] && [ -n "$CHANGED_TEST_FILES" ]; then
1702
1867
  CHANGED_TEST_FILES=$(echo "$CHANGED_TEST_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1703
1868
  fi
1704
1869
 
@@ -1884,14 +2049,20 @@ else
1884
2049
  CHANGED_SRC_FILES=$(echo "$CHANGED_SRC_FILES" | grep -vE '^plugins/[^/]+/' || true)
1885
2050
  # Mirror handling is identical for source files: their test-adjacent
1886
2051
  # paths must never reach vitest as root-project targets (#494).
2052
+ # Selection runs on repo-root relative paths (git diff output), before
2053
+ # any PROJECT_SUBDIR stripping, so the bundled-mirror prefixes match.
1887
2054
  SELECTED_SRC_FILES=$(select_root_suite_targets "$CHANGED_SRC_FILES")
1888
2055
  if [ "$SELECTED_SRC_FILES" != "$CHANGED_SRC_FILES" ]; then
1889
2056
  echo " ↪ Source selection adjusted for bundled mirrors (#494):"
1890
2057
  echo " was: $(echo "$CHANGED_SRC_FILES" | tr '\n' ' ')"
1891
2058
  echo " now: $(echo "$SELECTED_SRC_FILES" | tr '\n' ' ')"
1892
2059
  fi
1893
- # Strip PROJECT_SUBDIR prefix (same as CHANGED_TEST_FILES fix above)
1894
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$SELECTED_SRC_FILES" ]; then
2060
+ # Strip PROJECT_SUBDIR prefix ONLY when the hook cd'd into the
2061
+ # subdirectory (IN_PROJECT_SUBDIR=1; same gate as the CHANGED_TEST_FILES
2062
+ # fix above — at the repo root the paths must stay root-relative or the
2063
+ # related-test lookup and vitest targets resolve against the wrong dir,
2064
+ # #470)
2065
+ if [ "$IN_PROJECT_SUBDIR" = "1" ] && [ -n "$SELECTED_SRC_FILES" ]; then
1895
2066
  SELECTED_SRC_FILES=$(echo "$SELECTED_SRC_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1896
2067
  fi
1897
2068
  CHANGED_SRC_COUNT=$(echo "$SELECTED_SRC_FILES" | grep -c '.' || true)
@@ -2608,9 +2779,9 @@ else
2608
2779
  # real source path stays unbudgeted. Exclude it and let the mirroring be
2609
2780
  # governed by Mirror Parity in CI, which compares content rather than
2610
2781
  # re-linting it. (See the same exclusion in adapters/powershell.sh.)
2611
- MIRROR_EXCLUDE='^src/npm-package/'
2612
- NEW_FILES=$(git diff --cached --name-only --diff-filter=A | grep -v "$MIRROR_EXCLUDE" | tr '\n' ' ')
2613
- MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | grep -v "$MIRROR_EXCLUDE" | tr '\n' ' ')
2782
+ MIRROR_EXCLUDE="$MIRROR_PATH_FILTER"
2783
+ NEW_FILES=$(git diff --cached --name-only --diff-filter=A | grep -v "$MIRROR_EXCLUDE" | grep -v "$DASHBOARD_PATH_FILTER" | tr '\n' ' ')
2784
+ MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | grep -v "$MIRROR_EXCLUDE" | grep -v "$DASHBOARD_PATH_FILTER" | tr '\n' ' ')
2614
2785
 
2615
2786
  if [ -z "$NEW_FILES" ] && [ -z "$MODIFIED_FILES" ]; then
2616
2787
  echo " ✅ No new or modified files to check."
@@ -2648,7 +2819,17 @@ else
2648
2819
  echo "❌ BLOCKED - Boy Scout Rule violations detected ($VIOLATION_COUNT)"
2649
2820
  exit 1
2650
2821
  fi
2651
-
2822
+
2823
+ # REQ-4 (#452): the auto-init rewrites a committed file, which used to
2824
+ # surface as an unexplained dirty ` M .warnings-baseline.json` after
2825
+ # every commit that touched a first-time-modified file. Stage it so the
2826
+ # budget update travels with the commit that caused it; when the
2827
+ # baseline is unchanged (the common case) this is a no-op.
2828
+ if [ -n "$(git status --porcelain -- .warnings-baseline.json 2>/dev/null)" ]; then
2829
+ git add .warnings-baseline.json 2>/dev/null || true
2830
+ echo " ℹ️ .warnings-baseline.json auto-updated and staged with this commit."
2831
+ fi
2832
+
2652
2833
  echo " ✅ PASSED - Boy Scout Rule compliance."
2653
2834
  fi
2654
2835
  else
@@ -2691,9 +2872,73 @@ fi
2691
2872
  # ============================================================================
2692
2873
  # GATE 9: Build Integrity Check (TypeScript compilation + package + imports)
2693
2874
  # ============================================================================
2875
+
2876
+ # Java build-integrity dispatch (#507 S5, DR-003). The old gate only knew
2877
+ # TypeScript: a Maven project got a permanent SKIP -- "not a TypeScript
2878
+ # project" -- which is exactly the silent-no-coverage path this sprint
2879
+ # removes. Verdict codes (mirror of the java.sh G1 contract):
2880
+ # 0=PASS 2=BLOCK(compile failed) 3=SKIP+WARN(reason printed)
2881
+ resolve_build_integrity_timeout() {
2882
+ local file=".xp-gate/build-integrity-timeout" val=""
2883
+ if [ -f "$file" ]; then
2884
+ val=$(tr -d '[:space:]' < "$file")
2885
+ fi
2886
+ if [ -z "$val" ]; then
2887
+ echo "180"
2888
+ return 0
2889
+ fi
2890
+ case "$val" in
2891
+ ''|*[!0-9]*|0)
2892
+ echo "WARN - invalid $file value '${val}' (expected a positive integer), falling back to 180s" >&2
2893
+ echo "180"
2894
+ ;;
2895
+ *) echo "$val" ;;
2896
+ esac
2897
+ }
2898
+
2899
+ run_java_build_integrity() {
2900
+ local timeout_s build_bin
2901
+ # Wrapper first: mvnw pins the project's own Maven version.
2902
+ if [ -x "./mvnw" ]; then
2903
+ build_bin="./mvnw"
2904
+ elif command -v mvn >/dev/null 2>&1; then
2905
+ build_bin="mvn"
2906
+ else
2907
+ echo " ⚠️ Java changes staged but no Maven build tool found (mvnw/mvn)"
2908
+ echo " ⏭️ SKIPPED - Build integrity Java (no build tool)"
2909
+ return 3
2910
+ fi
2911
+
2912
+ timeout_s=$(resolve_build_integrity_timeout)
2913
+ if command -v timeout >/dev/null 2>&1; then
2914
+ timeout "$timeout_s" "$build_bin" -q -DskipTests compile 2>&1 | tail -20
2915
+ else
2916
+ "$build_bin" -q -DskipTests compile 2>&1 | tail -20
2917
+ fi
2918
+ local rc="${PIPESTATUS[0]}"
2919
+ if [ "$rc" -eq 0 ]; then
2920
+ echo " ✅ Java build integrity verified (mvn -q -DskipTests compile)."
2921
+ return 0
2922
+ fi
2923
+ if [ "$rc" -eq 124 ]; then
2924
+ echo " ⏱️ Java build timed out after ${timeout_s}s."
2925
+ echo " ⏭️ SKIPPED - Build integrity Java (timeout)"
2926
+ return 3
2927
+ fi
2928
+ echo " ❌ Java build failed (exit $rc)"
2929
+ return 2
2930
+ }
2931
+
2694
2932
  2>&1 echo ""
2695
2933
  2>&1 echo "→ Gate 9: Build integrity... "
2696
2934
  GATE_9_START=$(gate_start_ms)
2935
+ # Sub-status aggregation (#507 Delphi round-1 B-MAJOR-4): each half records
2936
+ # its own outcome; GATE_9_STATUS is aggregated after both. The previous
2937
+ # single-variable design made a Maven-only repo (TS half never runs, Java
2938
+ # half passes) record an ETERNAL SKIP -- a real compile verification that
2939
+ # never appeared as PASS, contradicting S5's own motivation.
2940
+ GATE_9_TS_STATUS="SKIP"
2941
+ GATE_9_JAVA_STATUS="SKIP"
2697
2942
  GATE_9_STATUS="SKIP"
2698
2943
 
2699
2944
  if [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
@@ -2710,15 +2955,15 @@ if [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
2710
2955
  CHANGED_FILES_CSV=$(echo "$CHANGED_FILES" | tr ' ' ',')
2711
2956
  GATE_9_OUTPUT=$(mktemp)
2712
2957
  if RUN_TSX_TIMEOUT=120s run_tsx "$GATE_9_SCRIPT" --changed-files "$CHANGED_FILES_CSV" --project-root "$PROJECT_ROOT" --timeout 115000 > "$GATE_9_OUTPUT" 2>&1; then
2713
- GATE_9_STATUS="PASS"
2958
+ GATE_9_TS_STATUS="PASS"
2714
2959
  echo " ✅ Build integrity check passed."
2715
2960
  else
2716
2961
  GATE_9_EXIT=$?
2717
2962
  if [ $GATE_9_EXIT -eq 124 ]; then
2718
- GATE_9_STATUS="SKIP"
2963
+ GATE_9_TS_STATUS="SKIP"
2719
2964
  echo " ⏱️ Build integrity check timed out (120s). SKIPPED."
2720
2965
  else
2721
- GATE_9_STATUS="BLOCK"
2966
+ GATE_9_TS_STATUS="BLOCK"
2722
2967
  cat "$GATE_9_OUTPUT"
2723
2968
  echo ""
2724
2969
  echo "❌ BLOCKED - Gate 9: Build integrity check failed"
@@ -2737,6 +2982,52 @@ else
2737
2982
  echo " ⏭️ SKIPPED - Build integrity (not a TypeScript project)"
2738
2983
  fi
2739
2984
 
2985
+ # Java dispatch (#507 S5): runs when .java files are staged, regardless of
2986
+ # whether the TypeScript half ran. Gradle-only repos SKIP+WARN (gradle
2987
+ # support is backlog; DR-003).
2988
+ if echo "$CHANGED_FILES" | grep -q '\.java$'; then
2989
+ if [ -f "pom.xml" ]; then
2990
+ JAVA_BI_EXIT=0
2991
+ run_java_build_integrity || JAVA_BI_EXIT=$?
2992
+ case "$JAVA_BI_EXIT" in
2993
+ 0)
2994
+ GATE_9_JAVA_STATUS="PASS"
2995
+ ;;
2996
+ 2)
2997
+ GATE_9_JAVA_STATUS="BLOCK"
2998
+ echo ""
2999
+ echo "❌ BLOCKED - Gate 9: Java build integrity check failed"
3000
+ echo "Fix the compile errors before committing."
3001
+ exit 1
3002
+ ;;
3003
+ *)
3004
+ GATE_9_JAVA_STATUS="SKIP"
3005
+ echo " ⏭️ SKIPPED - Build integrity Java (see reason above)"
3006
+ ;;
3007
+ esac
3008
+ elif [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
3009
+ echo " ⚠️ Java changes staged but this is a Gradle project - build integrity for Gradle is not implemented yet"
3010
+ echo " ⏭️ SKIPPED - Build integrity Java (Gradle backlog, #507 DR-003)"
3011
+ else
3012
+ echo " ℹ️ Java changes staged but no Maven/Gradle build file - skipping Java build integrity"
3013
+ fi
3014
+ fi
3015
+
3016
+ # Aggregate the two halves (#507 Delphi round-1 B-MAJOR-4): PASS when every
3017
+ # half that RAN passed (TS PASS + Java SKIP, or vice versa, both count),
3018
+ # SKIP only when NOTHING ran, BLOCK on any failure (normally unreachable --
3019
+ # failing halves exit 1 inline). This replaces the blind-review MINOR-1
3020
+ # single-variable guard: with explicit sub-statuses a Java PASS no longer
3021
+ # "resurrects" a TS SKIP -- each half keeps its own record and the gate
3022
+ # reflects the union of what was actually verified.
3023
+ GATE_9_STATUS="SKIP"
3024
+ if [ "$GATE_9_TS_STATUS" = "PASS" ] || [ "$GATE_9_JAVA_STATUS" = "PASS" ]; then
3025
+ GATE_9_STATUS="PASS"
3026
+ fi
3027
+ if [ "$GATE_9_TS_STATUS" = "BLOCK" ] || [ "$GATE_9_JAVA_STATUS" = "BLOCK" ]; then
3028
+ GATE_9_STATUS="BLOCK"
3029
+ fi
3030
+
2740
3031
  GATE_9_END=$(gate_start_ms)
2741
3032
  GATE_9_DURATION_MS=$((GATE_9_END - GATE_9_START))
2742
3033
  GATE_9_DURATION=$((GATE_9_DURATION_MS / 1000))
@@ -2769,7 +3060,7 @@ fi
2769
3060
  # Validates sprint state consistency (delphi-review APPROVED before BUILD)
2770
3061
  # Uses sprint-gate.sh for standalone validation logic
2771
3062
  # ============================================================================
2772
- GATE_11_START=$(date +%s)
3063
+ GATE_11_START=$(gate_start_ms)
2773
3064
  GATE_11_STATUS="PASS"
2774
3065
 
2775
3066
  # BEGIN SPRINT_GATE_SCRIPT RESOLUTION
@@ -2858,24 +3149,72 @@ generate_quality_report() {
2858
3149
  local BRANCH
2859
3150
  local PASSED_COUNT=0
2860
3151
  local TOTAL_GATES=12
3152
+ local SKIP_COUNT=0
3153
+ local WARN_COUNT=0
3154
+ local NONPASS_COUNT=0
2861
3155
 
2862
3156
  COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
2863
3157
  BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
2864
3158
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
2865
3159
 
3160
+ # Scoring per #507 S3: SKIP removes a gate from the denominator (a missing
3161
+ # tool must not drag the score down -- that was the "honest but punishing"
3162
+ # old behavior), WARN stays in the denominator but never counts as passed,
3163
+ # and FAIL/BLOCK count as non-passing items like before. NONPASS tracks
3164
+ # non-passing gates INSIDE the effective denominator only -- a skipped gate
3165
+ # is out of scope, not a failure.
3166
+ local gate_status gate_default
2866
3167
  for gate in 1 2 3 4 5 6 7 8 9 10 11 12; do
2867
3168
  local status_var="GATE_${gate}_STATUS"
2868
- if [ "${!status_var}" = "PASS" ]; then
2869
- PASSED_COUNT=$((PASSED_COUNT + 1))
2870
- fi
3169
+ # Default mirrors the JSON report rendering below EXACTLY (gates 1-11
3170
+ # PASS, gate 12 WARN): a gate that never assigned a status is "not
3171
+ # applicable" and must render and score identically. A previous scoring
3172
+ # default of FAIL produced self-contradictory reports -- the gate showed
3173
+ # PASS in JSON but scored as FAIL (#507 Delphi round-1 A-MAJOR-2).
3174
+ if [ "$gate" -eq 12 ]; then gate_default="WARN"; else gate_default="PASS"; fi
3175
+ gate_status="${!status_var:-$gate_default}"
3176
+ case "$gate_status" in
3177
+ PASS) PASSED_COUNT=$((PASSED_COUNT + 1)) ;;
3178
+ SKIP) SKIP_COUNT=$((SKIP_COUNT + 1)) ;;
3179
+ WARN) WARN_COUNT=$((WARN_COUNT + 1)); NONPASS_COUNT=$((NONPASS_COUNT + 1)) ;;
3180
+ *) NONPASS_COUNT=$((NONPASS_COUNT + 1)) ;;
3181
+ esac
2871
3182
  done
2872
3183
 
3184
+ local EFFECTIVE_TOTAL=$((TOTAL_GATES - SKIP_COUNT))
3185
+ local VERDICT="PASS"
3186
+ if [ "$EFFECTIVE_TOTAL" -eq 0 ]; then
3187
+ VERDICT="SKIP-ALL" # every gate skipped: never report a fake 10/10
3188
+ elif [ "$NONPASS_COUNT" -gt 0 ]; then
3189
+ if [ "$((NONPASS_COUNT - WARN_COUNT))" -gt 0 ]; then
3190
+ VERDICT="FAIL" # at least one FAIL/BLOCK among the effective gates
3191
+ else
3192
+ VERDICT="WARN" # all effective gates passed or warned; warns forbid PASS
3193
+ fi
3194
+ fi
3195
+
2873
3196
  local SCORE=0
2874
- if command -v bc >/dev/null 2>&1; then
2875
- SCORE=$(echo "scale=1; ($PASSED_COUNT / $TOTAL_GATES) * 10" | bc)
3197
+ if [ "$EFFECTIVE_TOTAL" -eq 0 ]; then
3198
+ SCORE="N/A"
2876
3199
  else
2877
- SCORE=$(awk "BEGIN {printf \"%.1f\", ($PASSED_COUNT / $TOTAL_GATES) * 10}")
2878
- fi
3200
+ # Single awk path (#507 Delphi round-1 B-MAJOR-2): bc's scale=1 truncates
3201
+ # the DIVISION before multiplying (8/9 -> 0.8, *10 -> 8.0) while awk
3202
+ # rounds the final result (8.9) -- the same commit scored differently on
3203
+ # machines with and without bc. awk(1) is POSIX-guaranteed: one code path,
3204
+ # one score.
3205
+ SCORE=$(awk "BEGIN {printf \"%.1f\", ($PASSED_COUNT / $EFFECTIVE_TOTAL) * 10}")
3206
+ fi
3207
+ local EFFECTIVE_PASS_RATE="N/A"
3208
+ if [ "$EFFECTIVE_TOTAL" -gt 0 ]; then
3209
+ EFFECTIVE_PASS_RATE=$(awk "BEGIN {printf \"%.1f\", ($PASSED_COUNT / $EFFECTIVE_TOTAL) * 100}")
3210
+ fi
3211
+ # JSON bodies need a number or null -- a literal N/A breaks every consumer.
3212
+ local SCORE_JSON="$SCORE"
3213
+ [ "$SCORE_JSON" = "N/A" ] && SCORE_JSON="null"
3214
+ # Same for the pass rate: numeric like `score`, not a quoted string --
3215
+ # mixed types in one object broke consumers (#507 Delphi round-1 B-minor-6).
3216
+ local EFFECTIVE_PASS_RATE_JSON="$EFFECTIVE_PASS_RATE"
3217
+ [ "$EFFECTIVE_PASS_RATE_JSON" = "N/A" ] && EFFECTIVE_PASS_RATE_JSON="null"
2879
3218
 
2880
3219
  local BS_PASSED=0
2881
3220
  local BS_BLOCKED=0
@@ -2915,8 +3254,11 @@ generate_quality_report() {
2915
3254
  "overall": {
2916
3255
  "gatesPassed": $PASSED_COUNT,
2917
3256
  "gatesTotal": $TOTAL_GATES,
2918
- "score": $SCORE,
2919
- "verdict": "$([ "$PASSED_COUNT" -eq "$TOTAL_GATES" ] && echo "PASS" || echo "PARTIAL")"
3257
+ "gatesSkipped": $SKIP_COUNT,
3258
+ "gatesEffective": $EFFECTIVE_TOTAL,
3259
+ "effectivePassRate": $EFFECTIVE_PASS_RATE_JSON,
3260
+ "score": $SCORE_JSON,
3261
+ "verdict": "$VERDICT"
2920
3262
  },
2921
3263
  "gates": {
2922
3264
  "gate1_static_analysis": {
@@ -3011,7 +3353,7 @@ ENDJSON
3011
3353
  GATES_JSON="${GATES_JSON}\"gate11\":{\"status\":\"${GATE_11_STATUS:-PASS}\",\"name\":\"Sprint Flow\"},"
3012
3354
  GATES_JSON="${GATES_JSON}\"gate12\":{\"status\":\"${GATE_12_STATUS:-WARN}\",\"name\":\"File Hygiene\"}}"
3013
3355
 
3014
- echo "{\"timestamp\":\"$TIMESTAMP\",\"commit\":\"$COMMIT_HASH\",\"branch\":\"$BRANCH\",\"score\":$SCORE,\"passed\":$PASSED_COUNT,\"total\":$TOTAL_GATES,\"gates\":$GATES_JSON,\"coverage\":\"$COV_PCT\",\"complexityWarnings\":${CC_WARNINGS:-0},\"principleWarnings\":${WARNING_COUNT:-0},\"boyScoutBlocked\":${BS_BLOCKED}}" >> "$HISTORY_FILE"
3356
+ echo "{\"timestamp\":\"$TIMESTAMP\",\"commit\":\"$COMMIT_HASH\",\"branch\":\"$BRANCH\",\"score\":$SCORE_JSON,\"passed\":$PASSED_COUNT,\"total\":$TOTAL_GATES,\"effectiveTotal\":$EFFECTIVE_TOTAL,\"effectivePassRate\":$EFFECTIVE_PASS_RATE_JSON,\"skipCount\":$SKIP_COUNT,\"verdict\":\"$VERDICT\",\"gates\":$GATES_JSON,\"coverage\":\"$COV_PCT\",\"complexityWarnings\":${CC_WARNINGS:-0},\"principleWarnings\":${WARNING_COUNT:-0},\"boyScoutBlocked\":${BS_BLOCKED}}" >> "$HISTORY_FILE"
3015
3357
 
3016
3358
  # ── Console output ────────────────────────────────────────────────────────
3017
3359
  echo ""
@@ -3032,7 +3374,7 @@ ENDJSON
3032
3374
  printf " %-45s %s\n" "Gate 11: Sprint Flow" "${GATE_11_STATUS:-PASS}"
3033
3375
  printf " %-45s %s\n" "Gate 12: File Hygiene" "${GATE_12_STATUS:-WARN}"
3034
3376
  echo ""
3035
- echo " Overall Score: $SCORE/10 | $PASSED_COUNT/$TOTAL_GATES gates passed"
3377
+ echo " Overall Score: $SCORE/10 | $PASSED_COUNT/$TOTAL_GATES gates passed (effective: $PASSED_COUNT/$EFFECTIVE_TOTAL, skipped: $SKIP_COUNT, verdict: $VERDICT)"
3036
3378
  echo " Branch status: $REPORT_FILE"
3037
3379
  echo " Run report: $PERSISTED_REPORT_FILE"
3038
3380
  echo " History saved: $HISTORY_FILE"