@boyingliu01/xp-gate 0.35.22 → 0.35.24

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 (41) hide show
  1. package/adapter-common.sh +24 -35
  2. package/adapters/go.sh +6 -11
  3. package/adapters/kotlin.sh +53 -8
  4. package/adapters/python.sh +6 -11
  5. package/gate-13-compiler-strictness.sh +15 -1
  6. package/hooks/adapter-common.sh +24 -35
  7. package/hooks/gate-13-compiler-strictness.sh +15 -1
  8. package/hooks/pre-commit +137 -9
  9. package/lib/__tests__/doctor-delphi.test.js +3 -3
  10. package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
  11. package/lib/__tests__/install-skill.test.js +107 -20
  12. package/lib/__tests__/uninstall-global-residue.test.js +230 -9
  13. package/lib/__tests__/update-skill.test.js +179 -55
  14. package/lib/install-skill.js +18 -3
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/lib/uninstall.js +47 -10
  17. package/lib/update-skill.js +96 -26
  18. package/mock-policy/AGENTS.md +3 -3
  19. package/mutation/AGENTS.md +3 -3
  20. package/package.json +1 -1
  21. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  22. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/dsh/package.json +1 -1
  26. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/opencode/package.json +1 -1
  30. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/plugins/qoder/plugin.json +1 -1
  34. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  35. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  36. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  37. package/principles/AGENTS.md +3 -3
  38. package/scripts/delphi-external-review.cjs +68 -1
  39. package/skills/delphi-review/AGENTS.md +3 -3
  40. package/skills/sprint-flow/AGENTS.md +3 -3
  41. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/adapter-common.sh CHANGED
@@ -104,41 +104,30 @@ detect_project_lang() {
104
104
  fi
105
105
  }
106
106
 
107
- route_to_adapter() {
108
- local action="$1"
109
- local lang
110
- lang=$(detect_project_lang)
111
-
112
- local adapter_file=""
113
-
114
- # 3-tier resolution matching pre-commit ADAPTER_DIR logic
115
- # Tier 1: Global flat layout (~/.config/xp-gate/adapters/lang.sh)
116
- if [ -f "$HOME/.config/xp-gate/adapters/${lang}.sh" ]; then
117
- adapter_file="$HOME/.config/xp-gate/adapters/${lang}.sh"
118
- # Tier 2: Project nested layout (githooks/adapters/lang.sh)
119
- elif [ -f "githooks/adapters/${lang}.sh" ]; then
120
- adapter_file="githooks/adapters/${lang}.sh"
121
- elif [ -f "./githooks/adapters/${lang}.sh" ]; then
122
- adapter_file="./githooks/adapters/${lang}.sh"
123
- fi
124
-
125
- if [ -n "$adapter_file" ]; then
126
- # shellcheck source=./githooks/adapters/lang.sh
127
- source "$adapter_file"
128
-
129
- # Execute the requested action
130
- case "$action" in
131
- "static_analysis") run_static_analysis ;;
132
- "lint") run_lint ;;
133
- "tests") run_tests ;;
134
- "coverage") run_coverage ;;
135
- *) return 1 ;;
136
- esac
137
- else
138
- echo "No adapter found for language: $lang (searched: ~/.config/xp-gate/adapters/, githooks/adapters/)"
139
- return 1
140
- fi
141
- }
107
+ # ---------------------------------------------------------------------------
108
+ # REMOVED in #688: route_to_adapter()
109
+ #
110
+ # It was a dispatcher with zero production callers. pre-commit sources an
111
+ # adapter and calls `run_static_analysis` / `run_lint` / `run_tests` /
112
+ # `run_coverage` directly; nothing ever went through this function, so its
113
+ # `case "$action"` enumeration was dead too -- including the "add an
114
+ # `architecture` arm and it'll work" assumption in #688, which would have been
115
+ # a no-op.
116
+ #
117
+ # Its two never-invoked dependants are gone with it: `run_architecture()` in
118
+ # adapters/python.sh and adapters/go.sh. Gate 6 already implements both checks
119
+ # INLINE in pre-commit, and does it better than the adapter versions did:
120
+ # * Python: detects 4 config carriers (.import-linter.yml /
121
+ # import_linter_config.yml / .importlinter / pyproject [tool.importlinter]),
122
+ # where the adapter checked 2, and BLOCKs when lint-imports is missing
123
+ # instead of `require_tool ... || return 0` silently passing.
124
+ # * Go: reads the checker's own exit status via PIPESTATUS[0] (#630), where a
125
+ # `arch-go check | head` pipeline would have reported head's status.
126
+ # Wiring the adapter version back in would therefore have been a regression,
127
+ # so the dead code was deleted instead of connected.
128
+ #
129
+ # tests: githooks/__tests__/issue-688-dead-dispatcher-removed.test.bats
130
+ # ---------------------------------------------------------------------------
142
131
 
143
132
  check_if_tool_available() {
144
133
  local tool_name="$1"
package/adapters/go.sh CHANGED
@@ -43,17 +43,12 @@ run_lint() {
43
43
  return $?
44
44
  }
45
45
 
46
- run_architecture() {
47
- if ! require_tool arch-go "arch-go (Go architecture checker)" 2>/dev/null; then
48
- echo "⚠ arch-go not available, installing..."
49
- go install github.com/arch-go/arch-go@latest 2>/dev/null
50
- require_tool arch-go "arch-go" || return 0
51
- fi
52
-
53
- echo "Running Go architecture checks (arch-go)..."
54
- arch-go check
55
- return $?
56
- }
46
+ # REMOVED in #688: run_architecture()
47
+ # Never invoked -- its only would-be caller, route_to_adapter(), had no callers
48
+ # either. Gate 6 implements arch-go in pre-commit (grep "arch-go"), and reads
49
+ # the checker's own exit status via PIPESTATUS[0] (#630) instead of letting a
50
+ # `| head` pipeline report head's. See githooks/adapter-common.sh for the full
51
+ # rationale.
57
52
 
58
53
  run_tests() {
59
54
  require_tool go "Go SDK" || return 1
@@ -14,30 +14,75 @@ run_static_analysis() {
14
14
  local build_system
15
15
  build_system=$(_detect_kotlin_build)
16
16
 
17
+ # (#706) A failed BUILD must not swallow a lint tool that works on its own.
18
+ #
19
+ # This used to `return 1` the moment Gradle/Maven failed to compile, so detekt
20
+ # -- which reads .kt sources directly and needs no build at all -- never ran.
21
+ # Measured on a machine whose Gradle cannot resolve the Kotlin plugin
22
+ # (`Failed to query the value of property 'buildFlowServiceProperty'`): the
23
+ # hook reported "SKIPPED - Code quality (tools unavailable or failed)" while
24
+ # `detekt --input .` on the same file reported two real findings. The gate
25
+ # blamed the toolchain for a verdict the toolchain never got to deliver.
26
+ #
27
+ # So: record the compilation failure, still run the standalone lint tool, and
28
+ # let the caller decide. The failure is reported, never hidden.
29
+ local build_failed=0
17
30
  if [ "$build_system" = "maven" ]; then
18
31
  mvn compile -q 2>&1 | grep -i "error\|fail" | sed -n '1,5p; 5q'
19
32
  if [ ${PIPESTATUS[0]} -ne 0 ]; then
20
33
  echo "❌ Maven compilation failed"
21
- return 1
34
+ build_failed=1
22
35
  fi
23
36
  elif [ "$build_system" = "gradle" ]; then
24
37
  gradle compileKotlin --quiet 2>&1 | grep -i "error\|fail" | sed -n '1,5p; 5q'
25
38
  if [ ${PIPESTATUS[0]} -ne 0 ]; then
26
39
  echo "❌ Gradle compilation failed"
27
- return 1
40
+ build_failed=1
28
41
  fi
29
42
  fi
30
43
 
44
+ # Standalone analyzers run regardless of the build outcome: their verdict does
45
+ # not depend on the build having succeeded.
46
+ #
47
+ # ktlint and detekt are tried in preference order, but a tool that is PRESENT
48
+ # and produces NOTHING is not the same as a tool that reached a verdict. An
49
+ # `elif` chain made the first present tool authoritative: a broken ktlint (or a
50
+ # shim on PATH, or ktlint 1.x refusing a `**/*.kt` glob) reported "No files
51
+ # matched" and detekt -- which works -- was never consulted. Measured here:
52
+ # `command -v ktlint` succeeded while `ktlint **/*.kt` matched zero files, so
53
+ # the real analyzer never ran.
54
+ local lint_rc=0 linted=0 ktlint_out=""
31
55
  if command -v ktlint &>/dev/null; then
32
- ktlint "**/*.kt" 2>&1 | sed -n '1,20p; 20q'
33
- return $?
34
- elif command -v detekt &>/dev/null; then
56
+ ktlint_out=$(ktlint "**/*.kt" 2>&1)
57
+ lint_rc=$?
58
+ printf '%s\n' "$ktlint_out" | sed -n '1,20p'
59
+ # Only an EXECUTED ktlint counts as having delivered a verdict. Three ways it
60
+ # can fail to: the binary cannot run (127/126), it reports "No files matched"
61
+ # (ktlint 1.8.0 no longer resolves `**/*.kt`), or it printed nothing at all.
62
+ # In every one of those cases detekt should speak instead of ktlint's silence
63
+ # being read as "clean".
64
+ if [ "$lint_rc" -ne 127 ] && [ "$lint_rc" -ne 126 ] \
65
+ && [ -n "$ktlint_out" ] \
66
+ && ! printf '%s\n' "$ktlint_out" | grep -q 'No files matched'; then
67
+ linted=1
68
+ fi
69
+ fi
70
+ if [ "$linted" -eq 0 ] && command -v detekt &>/dev/null; then
35
71
  detekt --input . 2>&1 | sed -n '1,20p; 20q'
36
- return $?
37
- else
72
+ lint_rc=${PIPESTATUS[0]}
73
+ linted=1
74
+ fi
75
+ if [ "$linted" -eq 0 ]; then
38
76
  echo "No Kotlin lint tools (ktlint/detekt)"
39
- return 0
77
+ # A missing lint tool is a SKIP, not a pass -- but it must not manufacture a
78
+ # failure either. Only the build result decides in that case.
79
+ [ "$build_failed" -eq 0 ] && return 0
80
+ return 1
40
81
  fi
82
+
83
+ # A lint finding is a finding whether or not the build also failed.
84
+ [ "$lint_rc" -ne 0 ] && return "$lint_rc"
85
+ return "$build_failed"
41
86
  }
42
87
 
43
88
  run_lint() {
@@ -180,17 +180,12 @@ run_lint() {
180
180
  fi
181
181
  }
182
182
 
183
- run_architecture() {
184
- if [ ! -f ".import-linter.yml" ] && [ ! -f "import_linter_config.yml" ]; then
185
- return 0
186
- fi
187
-
188
- require_tool lint-imports "import-linter" || return 0
189
-
190
- echo "Running Python architecture checks (import-linter)..."
191
- lint-imports
192
- return $?
193
- }
183
+ # REMOVED in #688: run_architecture()
184
+ # Never invoked -- its only would-be caller, route_to_adapter(), had no callers
185
+ # either. Gate 6 implements import-linter in pre-commit (grep "import-linter"),
186
+ # and more thoroughly: it detects 4 config carriers instead of the 2 checked
187
+ # here, and BLOCKs on a missing lint-imports rather than `|| return 0` passing.
188
+ # See githooks/adapter-common.sh for the full rationale.
194
189
 
195
190
  run_tests() {
196
191
  require_tool pytest "pytest" || return 1
@@ -525,7 +525,21 @@ gate13_javac_args() {
525
525
  gate13_count_lint_findings() {
526
526
  local src="${1:-}"
527
527
  [ -n "$src" ] || src=$(cat)
528
- printf '%s\n' "$src" | awk '
528
+ # (#701) Pin the locale to C for this awk, on purpose. In a UTF-8 locale gawk
529
+ # refuses to let `.` span bytes that are not valid UTF-8, and a Maven build on a
530
+ # zh_CN Windows box writes its reactor summary in GBK (stdout.encoding=GBK), so a
531
+ # module name such as "验证程序" arrives as d1 e9 d6 a4 b3 cc d0 f2 -- invalid
532
+ # UTF-8. The #673 exclusion rule (`/^\[INFO\].*\[jar\].../`) then fails to match
533
+ # and the line falls through to the open-category rule as a phantom finding.
534
+ # Measured (#701): the same input counts 0 under LC_ALL=C and 1 under
535
+ # LC_ALL=C.UTF-8, i.e. the verdict depended on the caller's environment rather
536
+ # than on the build. C locale makes `.` byte-oriented, so the match no longer
537
+ # depends on whether the module name happens to be ASCII.
538
+ #
539
+ # This costs nothing in matching power: every marker below (packaging types,
540
+ # `[category]`, `warning:`) is ASCII, and the localised unchecked/unsafe
541
+ # wordings are matched as byte sequences, which C locale handles exactly.
542
+ printf '%s\n' "$src" | LC_ALL=C awk '
529
543
  BEGIN { n = 0 }
530
544
  # (#673) Maven 3.9.7+ reactor summary appends the packaging type per module:
531
545
  # [INFO] whaleaudit-core [jar]
@@ -104,41 +104,30 @@ detect_project_lang() {
104
104
  fi
105
105
  }
106
106
 
107
- route_to_adapter() {
108
- local action="$1"
109
- local lang
110
- lang=$(detect_project_lang)
111
-
112
- local adapter_file=""
113
-
114
- # 3-tier resolution matching pre-commit ADAPTER_DIR logic
115
- # Tier 1: Global flat layout (~/.config/xp-gate/adapters/lang.sh)
116
- if [ -f "$HOME/.config/xp-gate/adapters/${lang}.sh" ]; then
117
- adapter_file="$HOME/.config/xp-gate/adapters/${lang}.sh"
118
- # Tier 2: Project nested layout (githooks/adapters/lang.sh)
119
- elif [ -f "githooks/adapters/${lang}.sh" ]; then
120
- adapter_file="githooks/adapters/${lang}.sh"
121
- elif [ -f "./githooks/adapters/${lang}.sh" ]; then
122
- adapter_file="./githooks/adapters/${lang}.sh"
123
- fi
124
-
125
- if [ -n "$adapter_file" ]; then
126
- # shellcheck source=./githooks/adapters/lang.sh
127
- source "$adapter_file"
128
-
129
- # Execute the requested action
130
- case "$action" in
131
- "static_analysis") run_static_analysis ;;
132
- "lint") run_lint ;;
133
- "tests") run_tests ;;
134
- "coverage") run_coverage ;;
135
- *) return 1 ;;
136
- esac
137
- else
138
- echo "No adapter found for language: $lang (searched: ~/.config/xp-gate/adapters/, githooks/adapters/)"
139
- return 1
140
- fi
141
- }
107
+ # ---------------------------------------------------------------------------
108
+ # REMOVED in #688: route_to_adapter()
109
+ #
110
+ # It was a dispatcher with zero production callers. pre-commit sources an
111
+ # adapter and calls `run_static_analysis` / `run_lint` / `run_tests` /
112
+ # `run_coverage` directly; nothing ever went through this function, so its
113
+ # `case "$action"` enumeration was dead too -- including the "add an
114
+ # `architecture` arm and it'll work" assumption in #688, which would have been
115
+ # a no-op.
116
+ #
117
+ # Its two never-invoked dependants are gone with it: `run_architecture()` in
118
+ # adapters/python.sh and adapters/go.sh. Gate 6 already implements both checks
119
+ # INLINE in pre-commit, and does it better than the adapter versions did:
120
+ # * Python: detects 4 config carriers (.import-linter.yml /
121
+ # import_linter_config.yml / .importlinter / pyproject [tool.importlinter]),
122
+ # where the adapter checked 2, and BLOCKs when lint-imports is missing
123
+ # instead of `require_tool ... || return 0` silently passing.
124
+ # * Go: reads the checker's own exit status via PIPESTATUS[0] (#630), where a
125
+ # `arch-go check | head` pipeline would have reported head's status.
126
+ # Wiring the adapter version back in would therefore have been a regression,
127
+ # so the dead code was deleted instead of connected.
128
+ #
129
+ # tests: githooks/__tests__/issue-688-dead-dispatcher-removed.test.bats
130
+ # ---------------------------------------------------------------------------
142
131
 
143
132
  check_if_tool_available() {
144
133
  local tool_name="$1"
@@ -525,7 +525,21 @@ gate13_javac_args() {
525
525
  gate13_count_lint_findings() {
526
526
  local src="${1:-}"
527
527
  [ -n "$src" ] || src=$(cat)
528
- printf '%s\n' "$src" | awk '
528
+ # (#701) Pin the locale to C for this awk, on purpose. In a UTF-8 locale gawk
529
+ # refuses to let `.` span bytes that are not valid UTF-8, and a Maven build on a
530
+ # zh_CN Windows box writes its reactor summary in GBK (stdout.encoding=GBK), so a
531
+ # module name such as "验证程序" arrives as d1 e9 d6 a4 b3 cc d0 f2 -- invalid
532
+ # UTF-8. The #673 exclusion rule (`/^\[INFO\].*\[jar\].../`) then fails to match
533
+ # and the line falls through to the open-category rule as a phantom finding.
534
+ # Measured (#701): the same input counts 0 under LC_ALL=C and 1 under
535
+ # LC_ALL=C.UTF-8, i.e. the verdict depended on the caller's environment rather
536
+ # than on the build. C locale makes `.` byte-oriented, so the match no longer
537
+ # depends on whether the module name happens to be ASCII.
538
+ #
539
+ # This costs nothing in matching power: every marker below (packaging types,
540
+ # `[category]`, `warning:`) is ASCII, and the localised unchecked/unsafe
541
+ # wordings are matched as byte sequences, which C locale handles exactly.
542
+ printf '%s\n' "$src" | LC_ALL=C awk '
529
543
  BEGIN { n = 0 }
530
544
  # (#673) Maven 3.9.7+ reactor summary appends the packaging type per module:
531
545
  # [INFO] whaleaudit-core [jar]
package/hooks/pre-commit CHANGED
@@ -2237,15 +2237,22 @@ else
2237
2237
  ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
2238
2238
  if [ -n "$ADAPTER_FILE" ] && source "$ADAPTER_FILE" 2>/dev/null; then
2239
2239
  echo "Running static analysis for $CURRENT_LANG..."
2240
+ # (#706) Capture the ADAPTER's verdict, not `head`'s.
2241
+ #
2242
+ # `run_static_analysis | head -30` makes `$?` the exit status of `head`,
2243
+ # which is 0 whenever it manages to read anything -- so ANALYSIS_EXIT was
2244
+ # always 0 and the "tools unavailable or failed" branch below was dead
2245
+ # code. Measured: an adapter returning 1 through this pipe still produced
2246
+ # PASSED. PIPESTATUS[0] carries the adapter's real status.
2240
2247
  run_static_analysis | head -30 2>/dev/null
2241
- ANALYSIS_EXIT=$?
2248
+ ANALYSIS_EXIT=${PIPESTATUS[0]}
2242
2249
  if [ "$ANALYSIS_EXIT" -ne 0 ]; then
2243
2250
  echo "⚠️ Static analysis had issues (may be non-blocking)"
2244
2251
  fi
2245
-
2252
+
2246
2253
  echo "Running lint check for $CURRENT_LANG..."
2247
2254
  run_lint | head -30 2>/dev/null
2248
- LINT_EXIT=$?
2255
+ LINT_EXIT=${PIPESTATUS[0]}
2249
2256
  if [ "$LINT_EXIT" -ne 0 ]; then
2250
2257
  echo "ℹ️ Lint check had issues (may be non-blocking)"
2251
2258
  fi
@@ -3692,10 +3699,40 @@ else
3692
3699
  if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
3693
3700
  echo "⚠️ No JaCoCo report found (subset test run) — not blocking."
3694
3701
  else
3702
+ # (#703) Diagnose BEFORE advising. The old message sent every user to
3703
+ # `mvn verify`; on a build path containing non-ASCII characters that
3704
+ # command is a dead end. Measured on a zh_CN Windows box at
3705
+ # D:\projects\SQL审计工具: the JaCoCo agent never writes jacoco.exec, it
3706
+ # does not throw, and Maven reports BUILD SUCCESS with
3707
+ # [INFO] Skipping JaCoCo execution due to missing execution data file.
3708
+ # so `mvn verify` "fixes" nothing and the user loops. Splitting the
3709
+ # diagnosis out lets each case get an action that can actually work.
3695
3710
  echo "❌ BLOCKED - No JaCoCo report found, so the ${COVERAGE_THRESHOLD:-80}% Java coverage"
3696
- echo " floor cannot be verified. Run 'mvn verify' or 'gradle jacocoTestReport'"
3697
- echo " so that target/site/jacoco/jacoco.csv exists (see also: JaCoCo detection"
3698
- echo " in adapters/java.sh)."
3711
+ echo " floor cannot be verified."
3712
+ # The distinguishing symptom is the jacoco.exec execution data file, not
3713
+ # a missing report: a report that failed to be GENERATED and an agent
3714
+ # that never WROTE are different problems with different fixes.
3715
+ echo " Likely cause: the JaCoCo agent did not write its execution data file"
3716
+ echo " (target/jacoco.exec), which is what 'Skipping JaCoCo execution due to"
3717
+ echo " missing execution data file' in the Maven log means."
3718
+ # A non-ASCII working directory is the one common cause where the usual
3719
+ # advice actively does not work, so it must be called out by name rather
3720
+ # than left for the user to discover by trying.
3721
+ case "$PWD" in
3722
+ *[!-~]*)
3723
+ echo " ⚠️ This build path contains non-ASCII characters: ${PWD}"
3724
+ echo " ⚠️ Re-running 'mvn verify' will NOT help on this path — JaCoCo"
3725
+ echo " silently skips writing execution data there. Build through an"
3726
+ echo " ASCII-only directory instead (e.g. a junction to the same tree,"
3727
+ echo " entered with cmd's 'pushd'/'cd /d' — Git Bash's cd resolves the"
3728
+ echo " junction back to the non-ASCII path, so it does not help)."
3729
+ ;;
3730
+ *)
3731
+ echo " To produce it, run 'mvn verify' or 'gradle jacocoTestReport' so that"
3732
+ echo " target/site/jacoco/jacoco.csv exists (see also: JaCoCo detection"
3733
+ echo " in adapters/java.sh)."
3734
+ ;;
3735
+ esac
3699
3736
  exit 1
3700
3737
  fi
3701
3738
  fi
@@ -4539,6 +4576,91 @@ resolve_build_integrity_timeout() {
4539
4576
  esac
4540
4577
  }
4541
4578
 
4579
+ # (#708) A Java build failure has one overwhelmingly common cause that the gate
4580
+ # used to say nothing about: the wrong JDK. The real reason is buried in the last
4581
+ # 20 lines of Maven output, while the gate's own verdict line was only
4582
+ # "❌ Java build failed (exit 1)".
4583
+ #
4584
+ # Measured: a pom with <maven.compiler.release>8</maven.compiler.release> built
4585
+ # with JDK 8 fails with "无效的标记: --release" (invalid flag: --release) — a
4586
+ # message that points at the compiler ARGUMENTS and at the project CONFIG while
4587
+ # actually pointing at the JDK on PATH. Getting from that message to "change the
4588
+ # JDK" takes one inference, and the gate's verdict line supplied no premise for
4589
+ # it. The person debugging this suspected the release setting, then the plugin
4590
+ # version, then .mvn/jvm.config — all wrong; the fix was a different JDK.
4591
+ #
4592
+ # So: on failure, print what JDK actually ran, what the project asks for, and —
4593
+ # when the output carries the tell — state the conclusion instead of the symptom.
4594
+ # Diagnostics must never change the verdict; this only adds lines.
4595
+ java_bi_report_jdk_context() {
4596
+ local build_out="$1"
4597
+ local java_version="" java_home_val="${JAVA_HOME:-}"
4598
+ # `java -version` writes to stderr on every JDK. Take the version line verbatim
4599
+ # rather than parsing it: this is a diagnostic, and the raw line is what a
4600
+ # person would paste into a bug report.
4601
+ if command -v java >/dev/null 2>&1; then
4602
+ java_version=$(java -version 2>&1 | sed -n '1p')
4603
+ fi
4604
+ if [ -n "$java_version" ]; then
4605
+ echo " JDK in use: ${java_version}"
4606
+ else
4607
+ echo " JDK in use: (java not on PATH)"
4608
+ fi
4609
+ if [ -n "$java_home_val" ]; then
4610
+ echo " JAVA_HOME: ${java_home_val}"
4611
+ else
4612
+ echo " JAVA_HOME: (unset)"
4613
+ fi
4614
+
4615
+ # What the project requires. Maven declares it three ways; Gradle declares it in
4616
+ # its build script. Report whichever exists — the comparison is the user's, but
4617
+ # only if both halves are visible.
4618
+ local declared=""
4619
+ if [ -f "pom.xml" ]; then
4620
+ local mvn_release mvn_source
4621
+ mvn_release=$(grep -o '<maven\.compiler\.release>[[:space:]]*[0-9][0-9]*' pom.xml 2>/dev/null \
4622
+ | sed 's/.*>[[:space:]]*//' | head -1)
4623
+ mvn_source=$(grep -o '<maven\.compiler\.source>[[:space:]]*[0-9][0-9]*' pom.xml 2>/dev/null \
4624
+ | sed 's/.*>[[:space:]]*//' | head -1)
4625
+ if [ -n "$mvn_release" ]; then
4626
+ declared="maven.compiler.release=${mvn_release}"
4627
+ elif [ -n "$mvn_source" ]; then
4628
+ declared="maven.compiler.source=${mvn_source}"
4629
+ fi
4630
+ fi
4631
+ if [ -z "$declared" ] && [ -f "build.gradle" ]; then
4632
+ local g_compat
4633
+ g_compat=$(grep -o "sourceCompatibility[[:space:]]*=[[:space:]]*['\"]\{0,1\}[0-9][0-9]*" build.gradle 2>/dev/null \
4634
+ | sed "s/.*[=][[:space:]]*['\"]\{0,1\}//" | head -1)
4635
+ [ -n "$g_compat" ] && declared="sourceCompatibility=${g_compat}"
4636
+ fi
4637
+ if [ -z "$declared" ] && [ -f "build.gradle.kts" ]; then
4638
+ local k_compat
4639
+ k_compat=$(grep -o 'sourceCompatibility[[:space:]]*=[[:space:]]*JavaVersion\.VERSION_[0-9_]*' build.gradle.kts 2>/dev/null \
4640
+ | sed 's/.*VERSION_//' | tr '_' '.' | head -1)
4641
+ [ -n "$k_compat" ] && declared="sourceCompatibility=${k_compat}"
4642
+ fi
4643
+
4644
+ if [ -n "$declared" ]; then
4645
+ echo " Project requires: ${declared}"
4646
+ else
4647
+ echo " Project requires: (no JDK floor declared in pom.xml/build.gradle)"
4648
+ fi
4649
+
4650
+ # The tell. `--release` only exists from JDK 9, so its rejection is a direct
4651
+ # statement about the JDK, not about the pom. Saying so converts a search into
4652
+ # a fix.
4653
+ case "$build_out" in
4654
+ *--release*)
4655
+ if printf '%s' "$build_out" | grep -qiE 'invalid flag|无效的标记|illegal option|unrecognized|unknown option'; then
4656
+ echo " 💡 The build passed '--release' to javac and the JDK rejected it."
4657
+ echo " '--release' requires JDK 9 or newer; the JDK above is older than that."
4658
+ echo " Switch to a JDK >= 9 (or remove the release/source setting if it is not intended)."
4659
+ fi
4660
+ ;;
4661
+ esac
4662
+ }
4663
+
4542
4664
  run_java_build_integrity() {
4543
4665
  local timeout_s build_bin
4544
4666
  # Wrapper first: mvnw pins the project's own Maven version.
@@ -4553,12 +4675,17 @@ run_java_build_integrity() {
4553
4675
  fi
4554
4676
 
4555
4677
  timeout_s=$(resolve_build_integrity_timeout)
4678
+ # (#708) Keep the output: the failure branch has to read it to tell a wrong-JDK
4679
+ # failure from any other one. Displayed through `tail -20` exactly as before, so
4680
+ # the visible build log is unchanged.
4681
+ local bi_out
4556
4682
  if command -v timeout >/dev/null 2>&1; then
4557
- timeout "$timeout_s" "$build_bin" -q -DskipTests compile 2>&1 | tail -20
4683
+ bi_out=$(timeout "$timeout_s" "$build_bin" -q -DskipTests compile 2>&1)
4558
4684
  else
4559
- "$build_bin" -q -DskipTests compile 2>&1 | tail -20
4685
+ bi_out=$("$build_bin" -q -DskipTests compile 2>&1)
4560
4686
  fi
4561
- local rc="${PIPESTATUS[0]}"
4687
+ local rc=$?
4688
+ printf '%s\n' "$bi_out" | tail -20
4562
4689
  if [ "$rc" -eq 0 ]; then
4563
4690
  echo " ✅ Java build integrity verified (mvn -q -DskipTests compile)."
4564
4691
  return 0
@@ -4569,6 +4696,7 @@ run_java_build_integrity() {
4569
4696
  return 3
4570
4697
  fi
4571
4698
  echo " ❌ Java build failed (exit $rc)"
4699
+ java_bi_report_jdk_context "$bi_out"
4572
4700
  return 2
4573
4701
  }
4574
4702
 
@@ -403,7 +403,7 @@ describe('doctor delphi #684 global config fallback', () => {
403
403
  return { logs, restore: () => spy.mockRestore() };
404
404
  }
405
405
 
406
- it('AC-684-01: a valid global config with no project copy PASSes instead of failing', async () => {
406
+ it('AC-721-01: a valid global config with no project copy PASSes instead of failing', async () => {
407
407
  const globalPath = writeGlobalConfig();
408
408
  const project = path.join(tmpHome, 'a-project-with-no-config');
409
409
  fs.mkdirSync(project, { recursive: true });
@@ -417,7 +417,7 @@ describe('doctor delphi #684 global config fallback', () => {
417
417
  expect(cfgLine).toMatch(/global/i);
418
418
  });
419
419
 
420
- it('AC-684-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
420
+ it('AC-721-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
421
421
  const project = path.join(tmpHome, 'bare-project');
422
422
  fs.mkdirSync(project, { recursive: true });
423
423
  const { logs, restore } = captureLogs();
@@ -436,7 +436,7 @@ describe('doctor delphi #684 global config fallback', () => {
436
436
  expect(cfgLine).toMatch(/not found/);
437
437
  });
438
438
 
439
- it('AC-684-03: a project config still wins over the global one', async () => {
439
+ it('AC-721-03: a project config still wins over the global one', async () => {
440
440
  const globalPath = writeGlobalConfig();
441
441
  const project = path.join(tmpHome, 'project-with-own-config');
442
442
  fs.mkdirSync(project, { recursive: true });
@@ -336,7 +336,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
336
336
  return { issues, check: checks.find((c) => c.name === `Skill: ${name}`) };
337
337
  }
338
338
 
339
- it('AC-681-01: same version but a missing chapter is WARN, not "Up to date"', () => {
339
+ it('AC-718-01: same version but a missing chapter is WARN, not "Up to date"', () => {
340
340
  const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
341
341
  // Keep the frontmatter (version included), drop a large slice of the body.
342
342
  const idx = bundled.indexOf('\n---', 4);
@@ -352,7 +352,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
352
352
  expect(issues).toBe(1);
353
353
  });
354
354
 
355
- it('AC-681-02: same version and same content is still "Up to date" (no false alarm)', () => {
355
+ it('AC-718-02: same version and same content is still "Up to date" (no false alarm)', () => {
356
356
  const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
357
357
  installSkill('delphi-review', bundled);
358
358
  const { issues, check } = runFreshness('delphi-review');
@@ -360,7 +360,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
360
360
  expect(issues).toBe(0);
361
361
  });
362
362
 
363
- it('AC-681-03: line endings alone still do not count as a difference', () => {
363
+ it('AC-718-03: line endings alone still do not count as a difference', () => {
364
364
  const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
365
365
  installSkill('delphi-review', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
366
366
  const { issues, check } = runFreshness('delphi-review');