@boyingliu01/xp-gate 0.31.3 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +269 -13
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/adapters/java.sh CHANGED
@@ -33,6 +33,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
33
  PLUGIN_DIR="$SCRIPT_DIR/plugins/p3c-java"
34
34
  WHALECLOUD_PLUGIN_DIR="$SCRIPT_DIR/plugins/whalecloud-java"
35
35
 
36
+ # #640: smart test selection helpers (strategy truth table + path-convention
37
+ # lookup). Sourced from lib/ so the logic stays unit-testable in isolation;
38
+ # `|| true` keeps a stale install (adapter present, lib missing) from breaking
39
+ # static analysis, which does not depend on it. run_coverage degrades to the
40
+ # full suite when the functions are absent.
41
+ _JAVA_SELECTION_LIB="$SCRIPT_DIR/../lib/java-test-selection.sh"
42
+ # shellcheck source=/dev/null
43
+ [ -f "$_JAVA_SELECTION_LIB" ] && . "$_JAVA_SELECTION_LIB"
44
+
36
45
  WC_JAVA_LINT_DEFAULT_TIMEOUT=180
37
46
 
38
47
  # ---------------------------------------------------------------------------
@@ -528,14 +537,257 @@ _detect_jacoco_configured() {
528
537
  return 1
529
538
  }
530
539
 
540
+ # ── #640: Java smart test selection ─────────────────────────────────────────
541
+ # The Java arm ran the FULL suite on every commit (measured: `mvn test
542
+ # jacoco:report` = 77.8s for 1079 tests whether one file or a hundred changed).
543
+ # TS (#286) and Python (#399) already had three-tier selection; Java did not,
544
+ # and PARTIAL_TEST_RUN was therefore never set on this path — the downgrade
545
+ # channel in Stage 2 was dead code for Java.
546
+ #
547
+ # This mirrors the Python selector exactly:
548
+ # 1. changed-tests — only test files changed: run exactly those.
549
+ # 2. related — sources changed, no tests: run what the path convention maps.
550
+ # 3. full — >20 changed tests, a build manifest changed, or no related
551
+ # tests found.
552
+ # Tiers 1/2 set PARTIAL_TEST_RUN so Stage 2 WARNS about subset coverage instead
553
+ # of blocking on it (subset coverage is meaningless as a global floor).
554
+ #
555
+ # Escape valve: XP_GATE_JAVA_TEST_SELECTION=off forces the full suite — for
556
+ # anyone who would rather pay the time than risk a missed transitive impact.
557
+ # Selection can only narrow the run, never widen it: every non-selected case
558
+ # falls back to `full`.
559
+
560
+ # Colon-separated list of changed paths, as handed in by pre-commit. Empty when
561
+ # the caller does not provide one (older pre-commit, direct adapter use), in
562
+ # which case we must NOT silently narrow the run — see _java_selection_enabled.
563
+ _JAVA_CHANGED_FILES="${GATE_CHANGED_FILES:-}"
564
+
565
+ # True when selection is usable: the escape valve is off AND the caller told us
566
+ # what changed. Without changed files we cannot select, and guessing "nothing
567
+ # changed" would run zero tests and report PASS — the exact failure mode the
568
+ # fail-closed contract forbids.
569
+ _java_selection_enabled() {
570
+ [ "${XP_GATE_JAVA_TEST_SELECTION:-on}" != "off" ] || return 1
571
+ [ -n "$_JAVA_CHANGED_FILES" ] || return 1
572
+ return 0
573
+ }
574
+
575
+ # Split the changed-file list into (test_count, src_count, deps_changed) and
576
+ # echo the strategy. Pure delegation to the lib so the truth table lives in one
577
+ # testable place.
578
+ _java_select_strategy() {
579
+ local test_count=0 src_count=0 deps_changed=0 path
580
+ local _java_files
581
+ # Newline-delimited, either from a colon list or a pre-split newline list.
582
+ _java_files=$(printf '%s' "$_JAVA_CHANGED_FILES" | tr ':' '\n')
583
+
584
+ while IFS= read -r path; do
585
+ [ -n "$path" ] || continue
586
+ case "$path" in
587
+ *.java) ;; # only Java sources influence the Java counts
588
+ *) continue ;;
589
+ esac
590
+ if java_is_test_file "$path"; then
591
+ test_count=$((test_count + 1))
592
+ else
593
+ src_count=$((src_count + 1))
594
+ fi
595
+ done <<EOF
596
+ $_java_files
597
+ EOF
598
+
599
+ # Build manifests are checked across ALL changed paths (a pom.xml change is
600
+ # not a .java file but invalidates every test).
601
+ # Build manifests are checked across ALL changed paths (a pom.xml change is
602
+ # not a .java file but invalidates every test). Newline-delimited and passed
603
+ # through a here-string rather than unquoted word splitting: a path with a
604
+ # space would otherwise arrive as two fragments, and on Windows a fragment
605
+ # containing a drive colon is indistinguishable from a separator.
606
+ while IFS= read -r path; do
607
+ [ -n "$path" ] || continue
608
+ if java_dependency_changed "$path"; then
609
+ deps_changed=1
610
+ break
611
+ fi
612
+ done <<EOF
613
+ $(printf '%s' "$_JAVA_CHANGED_FILES" | tr ':' '\n')
614
+ EOF
615
+
616
+ select_java_test_strategy "$test_count" "$src_count" "$deps_changed"
617
+ }
618
+
619
+ # Resolve the concrete test files to run for `related`, by mapping every changed
620
+ # non-test Java source through the path convention. Echoes one path per line.
621
+ #
622
+ # Exit code distinguishes the two outcomes that both produce empty stdout:
623
+ # 0 — lookup completed; no related test exists (caller escalates to full)
624
+ # 1 — the lookup itself broke (helper function missing). The caller must ALSO
625
+ # escalate to full, but must not silently treat a broken lookup as
626
+ # "nothing related" — that is the difference between a slow commit and a
627
+ # commit that never ran the tests it should have.
628
+ _java_related_test_files() {
629
+ command -v java_related_tests >/dev/null 2>&1 || return 1
630
+ local path seen="" rc=0
631
+ local _java_files
632
+ _java_files=$(printf '%s' "$_JAVA_CHANGED_FILES" | tr ':' '\n')
633
+ while IFS= read -r path; do
634
+ [ -n "$path" ] || continue
635
+ case "$path" in *.java) ;; *) continue ;; esac
636
+ java_is_test_file "$path" && continue
637
+ local related
638
+ while IFS= read -r related; do
639
+ [ -n "$related" ] || continue
640
+ case "$seen" in
641
+ *"|$related|"*) continue ;;
642
+ esac
643
+ seen="$seen|$related|"
644
+ printf '%s\n' "$related"
645
+ done <<EOF
646
+ $(java_related_tests "$path")
647
+ EOF
648
+ done <<EOF
649
+ $_java_files
650
+ EOF
651
+ return "$rc"
652
+ }
653
+
654
+ # Run the Maven suite. With a non-empty target list, runs only those classes;
655
+ # otherwise runs everything.
656
+ #
657
+ # $1 target list (comma-separated simple class names), may be empty
658
+ #
659
+ # Both fail-if-no-tests switches are required and they are NOT the same flag:
660
+ # -Dsurefire.failIfNoSpecifiedTests=false — a module with NO class matching
661
+ # -Dtest otherwise fails the build. In a multi-module repo the changed
662
+ # class lives in one module and every other module holds zero matches;
663
+ # without this the selective run is WORSE than the full one (measured:
664
+ # BUILD FAILURE on a 2-module project vs. a clean full-suite pass).
665
+ # -DfailIfNoTests=false — the older property some surefire versions read;
666
+ # kept for plugins predating the renamed key.
667
+ _run_maven_tests() {
668
+ local targets="$1"
669
+ if [ -n "$targets" ]; then
670
+ echo " ℹ️ Java selective run — $targets"
671
+ mvn test jacoco:report -q -Dtest="$targets" \
672
+ -Dsurefire.failIfNoSpecifiedTests=false \
673
+ -DfailIfNoTests=false 2>&1 | tail -10
674
+ else
675
+ mvn test jacoco:report -q 2>&1 | tail -10
676
+ fi
677
+ return "${PIPESTATUS[0]}"
678
+ }
679
+
680
+ # Run the Gradle suite, optionally filtered with repeated --tests flags.
681
+ #
682
+ # $1..$N simple class names, may be empty
683
+ _run_gradle_tests() {
684
+ if [ "$#" -gt 0 ] && [ -n "$1" ]; then
685
+ local filters=()
686
+ local name
687
+ for name in "$@"; do
688
+ [ -n "$name" ] || continue
689
+ filters+=("--tests" "$name")
690
+ done
691
+ echo " ℹ️ Java selective run — ${*}"
692
+ gradle test jacocoTestReport --quiet "${filters[@]}" 2>&1 | tail -10
693
+ else
694
+ gradle test jacocoTestReport --quiet 2>&1 | tail -10
695
+ fi
696
+ return "${PIPESTATUS[0]}"
697
+ }
698
+
531
699
  run_coverage() {
532
700
  local build_system
533
701
  build_system=$(_detect_java_build)
534
702
 
703
+ # Decide the selection once; every branch below honours it the same way.
704
+ local strategy="full" targets="" gradle_filters=()
705
+ if _java_selection_enabled; then
706
+ # lib/java-test-selection.sh is sourced by run_static_analysis's caller
707
+ # chain; guard so a direct invocation still works.
708
+ if command -v select_java_test_strategy >/dev/null 2>&1; then
709
+ strategy=$(_java_select_strategy)
710
+ case "$strategy" in
711
+ changed-tests)
712
+ # Feed ONLY actual test files to the selector. The strategy said
713
+ # "test files changed", but the raw list also carries every staged
714
+ # path; passing a production class here would have surefire report
715
+ # "No tests were executed" — and because of the failIfNoSpecifiedTests
716
+ # flag this run deliberately sets, that would exit 0 and count as a
717
+ # PASS while nothing ran. Filtering by java_is_test_file keeps the
718
+ # selector list and the strategy decision consistent.
719
+ local _ct_files=""
720
+ local _ct_path
721
+ while IFS= read -r _ct_path; do
722
+ [ -n "$_ct_path" ] || continue
723
+ case "$_ct_path" in *.java) ;; *) continue ;; esac
724
+ java_is_test_file "$_ct_path" || continue
725
+ _ct_files="$_ct_files$_ct_path"$'\n'
726
+ done <<EOF
727
+ $(printf '%s' "$_JAVA_CHANGED_FILES" | tr ':' '\n')
728
+ EOF
729
+ # shellcheck disable=SC2046
730
+ targets=$(java_maven_test_selector $(printf '%s' "$_ct_files"))
731
+ # shellcheck disable=SC2046,SC2207
732
+ gradle_filters=($(java_gradle_test_filters $(printf '%s' "$_ct_files")))
733
+ if [ -z "$targets" ] && [ "${#gradle_filters[@]}" -eq 0 ]; then
734
+ # Strategy said test files changed but none survived classification.
735
+ # Never run an empty inline list: widen to full rather than pass on
736
+ # a command that executes nothing.
737
+ strategy="full"
738
+ fi
739
+ ;;
740
+ related)
741
+ local related_files
742
+ related_files=$(_java_related_test_files)
743
+ local _rel_rc=$?
744
+ if [ "$_rel_rc" -ne 0 ]; then
745
+ # The lookup itself failed (helper missing). Escalate instead of
746
+ # pretending "nothing related" — an empty answer from a broken
747
+ # lookup and an empty answer from a real miss are different things.
748
+ strategy="full"
749
+ elif [ -n "$related_files" ]; then
750
+ # Word splitting IS the intent on both lines: $related_files is a
751
+ # space-separated path list and both helpers take it as $@.
752
+ # shellcheck disable=SC2046,SC2086
753
+ targets=$(java_maven_test_selector $related_files)
754
+ # shellcheck disable=SC2207,SC2086
755
+ gradle_filters=($(java_gradle_test_filters $related_files))
756
+ fi
757
+ if [ -z "$targets" ] && [ "${#gradle_filters[@]}" -eq 0 ]; then
758
+ # No related tests found -> tier 3, never "run nothing".
759
+ strategy="full"
760
+ fi
761
+ ;;
762
+ esac
763
+ fi
764
+ fi
765
+
766
+ case "$strategy" in
767
+ changed-tests|related)
768
+ # Subset run: the global coverage floor cannot be verified from it, so
769
+ # mark the run partial and let Stage 2 WARN (same contract as #399/#556).
770
+ #
771
+ # A plain `PARTIAL_TEST_RUN=true` does NOT work here: pre-commit calls
772
+ # `run_coverage` inside `$(...)`, so this function runs in a subshell and
773
+ # the assignment dies with it. Python's selective path gets away with the
774
+ # bare assignment only because it sets the flag at the call site, in the
775
+ # caller's own shell. Publish the decision through the file the caller
776
+ # named instead; the caller turns it into the flag.
777
+ if [ -n "${XP_GATE_PARTIAL_RUN_FILE:-}" ]; then
778
+ printf '%s\n' "$strategy" > "$XP_GATE_PARTIAL_RUN_FILE" 2>/dev/null || true
779
+ fi
780
+ echo " ℹ️ Java: selective run ($strategy)"
781
+ ;;
782
+ *)
783
+ echo " ℹ️ Java: full suite"
784
+ ;;
785
+ esac
786
+
535
787
  if [ "$build_system" = "maven" ]; then
536
788
  if _detect_jacoco_configured "$build_system"; then
537
- mvn test jacoco:report -q 2>&1 | tail -10
538
- return "${PIPESTATUS[0]}"
789
+ _run_maven_tests "$targets"
790
+ return $?
539
791
  else
540
792
  echo "ℹ️ JaCoCo not configured in pom.xml — SKIP coverage check"
541
793
  echo " To enable: add jacoco-maven-plugin to your pom.xml"
@@ -543,8 +795,8 @@ run_coverage() {
543
795
  fi
544
796
  elif [ "$build_system" = "gradle" ]; then
545
797
  if _detect_jacoco_configured "$build_system"; then
546
- gradle jacocoTestReport --quiet 2>&1 | tail -10
547
- return "${PIPESTATUS[0]}"
798
+ _run_gradle_tests "${gradle_filters[@]}"
799
+ return $?
548
800
  else
549
801
  echo "ℹ️ JaCoCo not configured in Gradle build — SKIP coverage check"
550
802
  echo " To enable: add 'id \"jacoco\"' to your build.gradle plugins block"
@@ -59,8 +59,36 @@ _detect_pwsh() {
59
59
  return 1
60
60
  }
61
61
 
62
+ # List PowerShell SOURCE files under the current directory, without walking the
63
+ # pruned artifact directories.
64
+ #
65
+ # #620: the caller used to pass '*.ps1', so a .psm1 module file was invisible --
66
+ # a repo whose code lives in modules (the normal layout for anything that ships
67
+ # one) got "no PowerShell files found outside the pruned directories" on every
68
+ # commit, and the message blamed node_modules/dist instead of the glob. Our own
69
+ # complexity path (gate-3.sh) already treated '.ps(m)?1$' as PowerShell, so the
70
+ # two gates disagreed about what a PowerShell file is.
71
+ #
72
+ # The extension set is owned here rather than at each call site: one definition,
73
+ # so Gate 1 and Gate 3 cannot drift apart again. Callers that want a narrower
74
+ # population (e.g. only *.Tests.ps1) still pass an explicit pattern.
75
+ _POWERSHELL_SOURCE_PATTERNS=('*.ps1' '*.psm1')
76
+
62
77
  _find_powershell_files() {
63
- local name_pattern="$1"
78
+ local name_pattern="${1:-}"
79
+
80
+ # Default to every PowerShell source file. A bare name_pattern keeps the old
81
+ # single-extension behaviour for callers that pass one deliberately.
82
+ local -a name_test=()
83
+ if [ -z "$name_pattern" ]; then
84
+ local p
85
+ for p in "${_POWERSHELL_SOURCE_PATTERNS[@]}"; do
86
+ if [ "${#name_test[@]}" -gt 0 ]; then name_test+=('-o'); fi
87
+ name_test+=('-name' "$p")
88
+ done
89
+ else
90
+ name_test=('-name' "$name_pattern")
91
+ fi
64
92
 
65
93
  # #573: repo-local extra prune entries. The list below covers the artifact
66
94
  # directories xp-gate itself produces, but a consuming repo can have its own
@@ -149,7 +177,7 @@ _find_powershell_files() {
149
177
  -path './src/npm-package/plugins' \
150
178
  "${prune_args[@]+"${prune_args[@]}"}" \
151
179
  \) -prune \) -o \
152
- \( -type f -name "$name_pattern" -print \) 2>/dev/null
180
+ \( -type f \( "${name_test[@]}" \) -print \) 2>/dev/null
153
181
  }
154
182
 
155
183
  _powershell_invocation() {
@@ -290,16 +318,27 @@ run_static_analysis() {
290
318
  # .git, node_modules, dist, coverage and the rest. The two paths disagreed
291
319
  # only because this one never called it. Now both use it, so "what this
292
320
  # adapter refuses to analyse" has one definition.
293
- local legacy_files
294
- legacy_files=$(_find_powershell_files '*.ps1')
295
- if [ -z "$legacy_files" ]; then
321
+ # #620: no pattern means "every PowerShell source file", so .psm1 modules
322
+ # are analysed too. This used to pass '*.ps1', which made a module-only repo
323
+ # look empty and reported the emptiness as a pruning problem.
324
+ #
325
+ # Read into an ARRAY, one path per line. The previous shape captured the
326
+ # list into a string and let word splitting expand it again, which silently
327
+ # split any path containing a space. Reading line by line keeps the paths
328
+ # intact; `mapfile` would do the same but is not guaranteed by the bash
329
+ # versions this hook supports.
330
+ local -a legacy_files=()
331
+ while IFS= read -r _ps_file; do
332
+ [ -n "$_ps_file" ] && legacy_files+=("$_ps_file")
333
+ done < <(_find_powershell_files)
334
+ if [ "${#legacy_files[@]}" -eq 0 ]; then
296
335
  # Nothing to analyse is NOT a pass: say so via the reason protocol the
297
336
  # module-missing branch uses, so it stays visible in the gate output.
298
337
  echo "XP_G1_REASON: no PowerShell files found outside the pruned directories"
299
338
  return 3
300
339
  fi
301
340
  local legacy_array
302
- legacy_array=$(_powershell_path_array $legacy_files)
341
+ legacy_array=$(_powershell_path_array "${legacy_files[@]}")
303
342
  PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
304
343
  if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
305
344
  Write-Output 'PSSA_MODULE_MISSING'
package/bin/xp-gate.js CHANGED
@@ -460,7 +460,11 @@ for (let i = 0; i <= 11; i++) {
460
460
  const gateInfo = gateRunner.GATE_REGISTRY[gateId];
461
461
  COMMANDS[`gate-${gateId}`] = {
462
462
  description: `Run Gate ${gateId}: ${gateInfo?.name || 'Unknown'}`,
463
- run: subargs => gateRunner.runGate(gateId, subargs[0]).then(code => process.exit(code)),
463
+ // #618: a gate that ran nothing yields GATE_NOT_RUN. process.exit() would
464
+ // coerce that string to 0 (a pass), so map it to 2 -- "did not run" must not
465
+ // exit the same way as "ran clean".
466
+ run: subargs => gateRunner.runGate(gateId, subargs[0])
467
+ .then(code => process.exit(code === gateRunner.GATE_NOT_RUN ? 2 : code)),
464
468
  usage: `xp-gate gate-${gateId} [<path>]`
465
469
  };
466
470
  }
@@ -471,7 +475,9 @@ for (const gateId of ['python-health', 'test-layers', 'pbt']) {
471
475
  if (gateInfo) {
472
476
  COMMANDS[gateId] = {
473
477
  description: gateInfo.description,
474
- run: subargs => gateRunner.runGate(gateId, subargs[0]).then(code => process.exit(code)),
478
+ // #618: same coercion guard as the numbered gates above.
479
+ run: subargs => gateRunner.runGate(gateId, subargs[0])
480
+ .then(code => process.exit(code === gateRunner.GATE_NOT_RUN ? 2 : code)),
475
481
  usage: `xp-gate ${gateId} [<path>]`
476
482
  };
477
483
  }
@@ -1,12 +1,99 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import fs from 'fs/promises';
3
+ import fss from 'node:fs';
3
4
  import path from 'path';
4
5
  import os from 'os';
5
- import { runTscCheck, runPackCheck } from '../gate-10';
6
+ import { runTscCheck, runPackCheck, resolveImportPath } from '../gate-10';
6
7
 
7
8
  // Increase test timeout for subprocess calls (tsc, npm pack)
8
9
  vi.setConfig({ testTimeout: 30000 });
9
10
 
11
+ /**
12
+ * @test REQ-627
13
+ * @intent `resolveImportPath` had ZERO direct tests before this file gained them.
14
+ * It decides whether an import in changed code points at a real file, so
15
+ * a silent regression here makes Gate 10 pass while imports are broken.
16
+ * The `.js -> .ts` fallback is the subtle part: TypeScript's ESM output
17
+ * rewrites `./x.ts` to `./x.js`, so a source tree has `x.js` specifiers
18
+ * that must resolve to `x.ts`. Removing that arm left every existing test
19
+ * green, which is exactly why these cases are needed.
20
+ * @covers AC-627-01, AC-627-02, AC-627-03
21
+ */
22
+
23
+ describe('resolveImportPath (build-integrity)', () => {
24
+ let dir: string;
25
+
26
+ beforeEach(async () => {
27
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gate10-resolve-'));
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await fs.rm(dir, { recursive: true, force: true });
32
+ });
33
+
34
+ const from = (): string => path.join(dir, 'importer.ts');
35
+
36
+ /**
37
+ * Create a fixture file. The write is wrapped so a failure is reported with
38
+ * the file name instead of surfacing as a bare fs error somewhere later — a
39
+ * test whose fixture silently failed to materialise asserts nothing useful.
40
+ */
41
+ function writeSource(name: string, content: string): void {
42
+ try {
43
+ fss.writeFileSync(path.join(dir, name), content);
44
+ } catch (error) {
45
+ throw new Error(`could not create fixture ${name}: ${String(error)}`);
46
+ }
47
+ }
48
+
49
+ /** Create a fixture subdirectory, for the index-file fallback case. */
50
+ function makeDir(name: string): void {
51
+ try {
52
+ fss.mkdirSync(path.join(dir, name));
53
+ } catch (error) {
54
+ throw new Error(`could not create fixture dir ${name}: ${String(error)}`);
55
+ }
56
+ }
57
+
58
+ it('AC-627-01: resolves an explicit .js specifier to the sibling .ts source', async () => {
59
+ writeSource('target.ts', 'export const x = 1;\n');
60
+ // The specifier says .js; only target.ts exists. ESM output depends on this.
61
+ expect(resolveImportPath('./target.js', from())).toBe(path.join(dir, 'target.ts'));
62
+ });
63
+
64
+ it('AC-627-01: prefers an existing .js file over the .ts fallback', async () => {
65
+ writeSource('both.js', 'module.exports = {};\n');
66
+ writeSource('both.ts', 'export const x = 1;\n');
67
+ expect(resolveImportPath('./both.js', from())).toBe(path.join(dir, 'both.js'));
68
+ });
69
+
70
+ it('AC-627-02: an explicit non-.js extension must match exactly (no fallback)', async () => {
71
+ writeSource('mod.mjs', 'export {};\n');
72
+ // .mjs exists, resolves directly
73
+ expect(resolveImportPath('./mod.mjs', from())).toBe(path.join(dir, 'mod.mjs'));
74
+ // .cjs does NOT exist, and there is no .cjs -> .ts fallback
75
+ expect(resolveImportPath('./missing.cjs', from())).toBeNull();
76
+ });
77
+
78
+ it('AC-627-03: an extensionless import tries known extensions, then index files', async () => {
79
+ writeSource('plain.ts', 'export const y = 2;\n');
80
+ expect(resolveImportPath('./plain', from())).toBe(path.join(dir, 'plain.ts'));
81
+
82
+ makeDir('pkg');
83
+ writeSource('pkg/index.ts', 'export {};\n');
84
+ expect(resolveImportPath('./pkg', from())).toBe(path.join(dir, 'pkg', 'index.ts'));
85
+ });
86
+
87
+ it('AC-627-03: a non-relative specifier is not resolved at all', () => {
88
+ expect(resolveImportPath('lodash', from())).toBeNull();
89
+ expect(resolveImportPath('@scope/pkg', from())).toBeNull();
90
+ });
91
+
92
+ it('AC-627-03: a relative import to nothing returns null', async () => {
93
+ expect(resolveImportPath('./nope', from())).toBeNull();
94
+ });
95
+ });
96
+
10
97
  /**
11
98
  * @test Gate 10 runTscCheck
12
99
  * @intent Verify tsc --noEmit check behavior for build integrity gate
@@ -281,34 +281,69 @@ function tryAccess(filePath: string): boolean {
281
281
  }
282
282
  }
283
283
 
284
- export function resolveImportPath(importPath: string, fromFile: string): string | null {
285
- if (!isRelativeImport(importPath)) return null;
284
+ /**
285
+ * Extensions tried when an import has none, in precedence order.
286
+ *
287
+ * `.js` first because a TS source that imports `./x` is compiled to `x.js`, so
288
+ * the emitted name is the most likely intent in a mixed tree.
289
+ */
290
+ const IMPORT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
286
291
 
287
- const fromDir = path.dirname(fromFile);
288
- const resolved = path.resolve(fromDir, importPath);
292
+ /** Directory-index filenames tried last, when no sibling file matched. */
293
+ const INDEX_FILES = ['index.ts', 'index.js'];
289
294
 
290
- const ext = path.extname(importPath);
291
- if (ext) {
292
- if (tryAccess(resolved)) return resolved;
293
- if (ext === '.js') {
294
- if (tryAccess(resolved.slice(0, -3) + '.ts')) return resolved.slice(0, -3) + '.ts';
295
- if (tryAccess(resolved.slice(0, -3) + '.tsx')) return resolved.slice(0, -3) + '.tsx';
296
- }
297
- return null;
295
+ /**
296
+ * Source extensions an explicit `.js` import may actually refer to.
297
+ *
298
+ * TypeScript's ESM output rewrites `./x.ts` to `./x.js`, so an import that names
299
+ * a `.js` file legitimately resolves to the TypeScript source beside it. Checked
300
+ * in order, and only for `.js` -- an explicit `.ts` or `.mjs` must match exactly.
301
+ */
302
+ const JS_TO_SOURCE_EXTENSIONS = ['.ts', '.tsx'];
303
+
304
+ /**
305
+ * Resolve an import that already names an extension.
306
+ *
307
+ * A `.js` specifier may point at TypeScript source (ESM output rewrites `.ts` to
308
+ * `.js`), so it gets one extra look at the sibling source extensions. Every other
309
+ * extension must match the file on disk exactly.
310
+ */
311
+ function resolveExplicitExtension(resolved: string, ext: string): string | null {
312
+ if (tryAccess(resolved)) return resolved;
313
+ if (ext !== '.js') return null;
314
+ const stem = resolved.slice(0, -'.js'.length);
315
+ for (const sourceExt of JS_TO_SOURCE_EXTENSIONS) {
316
+ if (tryAccess(stem + sourceExt)) return stem + sourceExt;
298
317
  }
318
+ return null;
319
+ }
299
320
 
300
- for (const tryExt of ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']) {
321
+ /**
322
+ * Resolve an extensionless import: try the known extensions in order, then the
323
+ * directory-index names.
324
+ */
325
+ function resolveBareImport(resolved: string): string | null {
326
+ for (const tryExt of IMPORT_EXTENSIONS) {
301
327
  if (tryAccess(resolved + tryExt)) return resolved + tryExt;
302
328
  }
303
-
304
- for (const idxName of ['index.ts', 'index.js']) {
329
+ for (const idxName of INDEX_FILES) {
305
330
  const candidate = path.join(resolved, idxName);
306
331
  if (tryAccess(candidate)) return candidate;
307
332
  }
308
-
309
333
  return null;
310
334
  }
311
335
 
336
+ export function resolveImportPath(importPath: string, fromFile: string): string | null {
337
+ if (!isRelativeImport(importPath)) return null;
338
+
339
+ const resolved = path.resolve(path.dirname(fromFile), importPath);
340
+ const ext = path.extname(importPath);
341
+
342
+ return ext
343
+ ? resolveExplicitExtension(resolved, ext)
344
+ : resolveBareImport(resolved);
345
+ }
346
+
312
347
  /**
313
348
  * Runs the import check on a list of changed files.
314
349
  * Verifies that all relative imports in the changed files resolve to files
@@ -0,0 +1,15 @@
1
+ # XP-Gate DSH bundle patch (npm main package).
2
+ #
3
+ # Mounts the xp-gate tool plugin so that
4
+ # dsh plugin add @boyingliu01/xp-gate
5
+ # is sufficient on its own — no separate install of
6
+ # @boyingliu01/dsh-plugin-xp-gate is required.
7
+ #
8
+ # The entry point is the package-internal subpath "./dsh", which resolves via
9
+ # this package.json's `exports` map to plugins/dsh/lib/index.js (ESM). Shipping
10
+ # the code inside the same tarball keeps `dependencies` empty, preserving the
11
+ # zero-install distribution contract.
12
+
13
+ - insert:
14
+ - id: tool-xp-gate
15
+ name: '@boyingliu01/xp-gate/dsh'