@boyingliu01/xp-gate 0.34.0 → 0.34.1

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 (33) hide show
  1. package/gate-13-compiler-strictness.sh +129 -4
  2. package/gate-4.sh +8 -1
  3. package/hooks/gate-13-compiler-strictness.sh +129 -4
  4. package/hooks/gate-4.sh +8 -1
  5. package/lib/__tests__/foreign-hooks-global.test.js +125 -0
  6. package/lib/__tests__/uninstall-global-residue.test.js +160 -0
  7. package/lib/doctor.js +70 -0
  8. package/lib/mattpocock-license-verdict.json +1 -1
  9. package/lib/uninstall.js +225 -1
  10. package/mock-policy/AGENTS.md +2 -2
  11. package/mutation/AGENTS.md +2 -2
  12. package/package.json +1 -1
  13. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  15. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  16. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  17. package/plugins/dsh/package.json +1 -1
  18. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  19. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  20. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  21. package/plugins/opencode/package.json +1 -1
  22. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  23. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  24. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  25. package/plugins/qoder/plugin.json +1 -1
  26. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  27. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  28. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/principles/AGENTS.md +2 -2
  30. package/principles/rules/solid/dip.ts +94 -14
  31. package/skills/delphi-review/AGENTS.md +2 -2
  32. package/skills/sprint-flow/AGENTS.md +2 -2
  33. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -501,6 +501,108 @@ gate13_javac_args() {
501
501
  printf '%s\n' "-Xlint:all,-processing -d"
502
502
  }
503
503
 
504
+ # ---------------------------------------------------------------------------
505
+ # AC-670-34 / AC-670-35 (#659) — counting javac lint findings, locale-proof.
506
+ #
507
+ # The previous detector was an allow-list of English `[category]` names applied to
508
+ # `[WARNING]` lines. Two independent ways it returned 0 on defective code:
509
+ #
510
+ # 1. NOVELTY: a JDK adds a category the list does not name (`future-category`),
511
+ # or the fix relies on the list staying in sync with javac. A fixed allow-list
512
+ # over an open enum is a fail-OPEN design.
513
+ # 2. LOCALISATION: on a non-English build javac's own hint drifts, and the
514
+ # `[WARNING]` prefix may too. Measured on a zh_CN Maven build, the tell is
515
+ # "使用了未经检查或不安全的操作 ... 请使用 -Xlint:unchecked 重新编译" -- a message
516
+ # javac emits precisely when -Xlint was NOT in effect.
517
+ #
518
+ # Fail-closed policy (#499/#570) applied to text matching: count any bracketed
519
+ # lowercase category (whatever it is named), and treat javac's "you should have
520
+ # used -Xlint" hint as a finding too -- the code has an unchecked/unsafe operation
521
+ # whether or not the flag that would name it was on.
522
+ #
523
+ # Prints a single integer. Never fails (always exits 0).
524
+ # ---------------------------------------------------------------------------
525
+ gate13_count_lint_findings() {
526
+ local src="${1:-}"
527
+ [ -n "$src" ] || src=$(cat)
528
+ printf '%s\n' "$src" | awk '
529
+ BEGIN { n = 0 }
530
+ # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
+ # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
+ # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
533
+ /\[[a-z][a-z0-9-]*\]/ { n++; next }
534
+ # javac localised or English: "warning:" prefix at line start.
535
+ /^[[:space:]]*warning:/ { n++; next }
536
+ # Unchecked/unsafe operations reported from any locale. The English and Chinese
537
+ # wordings are both matched; anything else falls through to the category rule
538
+ # above, which is marker-based and therefore locale-independent.
539
+ /unchecked|unsafe|未经检查|不安全/ { n++; next }
540
+ END { print n + 0 }
541
+ '
542
+ return 0
543
+ }
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # AC-670-32 (#659) — PROVE that -Xlint takes effect, do not assume it.
547
+ #
548
+ # "Can assemble the flag" is not "the flag works". `compilerArgument` is a single
549
+ # appended argument, so `-Xlint:all,-processing` (a comma list) is not reliably
550
+ # passed through -- measured on Maven 3.9.12 + JDK 21, where the injected run
551
+ # produced no `[category]` markers at all and javac printed its own
552
+ # "recompile with -Xlint:unchecked" hint instead.
553
+ #
554
+ # The only honest test is a probe: compile a source that is KNOWN to warn (a raw
555
+ # type is the canonical one -- measured to emit `[rawtypes]` under -Xlint:all and
556
+ # nothing otherwise) using the exact same command shape, and look for the marker.
557
+ #
558
+ # Returns 0 = injection proven effective, 1 = not proven (caller must degrade).
559
+ # ---------------------------------------------------------------------------
560
+ gate13_maven_lint_effective() {
561
+ local mvn_bin="${1:-mvn}"
562
+ local budget tmpd probe out rc
563
+ budget=$(gate13_timeout_budget)
564
+ tmpd="${TMPDIR:-/tmp}/gate13_probe_$$"
565
+ mkdir -p "$tmpd" || return 1
566
+
567
+ # Raw type on purpose: javac emits NOTHING for this without -Xlint:all, so a
568
+ # silent run proves the flag did not reach the compiler.
569
+ probe="$tmpd/Gate13Probe.java"
570
+ cat > "$probe" <<'PROBE'
571
+ import java.util.ArrayList;
572
+ import java.util.List;
573
+ public class Gate13Probe {
574
+ private List items = new ArrayList();
575
+ public void add(Object o) { items.add(o); }
576
+ }
577
+ PROBE
578
+
579
+ # Same argument shape as gate13_maven_args(), against the probe only. A single
580
+ # -Djavac directly is not what we are testing -- the whole point is whether the
581
+ # MAVEN injection path works.
582
+ if command -v timeout >/dev/null 2>&1; then
583
+ out=$(timeout "$budget" javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
584
+ else
585
+ out=$(javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
586
+ fi
587
+ rc=$?
588
+ rm -rf "$tmpd"
589
+
590
+ # If the DIRECT javac probe cannot even produce a rawtypes warning, the JDK
591
+ # itself is not behaving as measured, so nothing about the Maven path can be
592
+ # concluded -- report "not proven" rather than "effective".
593
+ if [ "$rc" -ne 0 ]; then
594
+ return 1
595
+ fi
596
+ printf '%s\n' "$out" | grep -q 'rawtypes'
597
+ }
598
+
599
+ # Is `-Xlint` reachable at all on this machine? The Maven probe only makes sense
600
+ # when a raw-type warning is obtainable; when javac cannot produce one, callers
601
+ # stay in degraded mode instead of claiming a clean lint.
602
+ gate13_maven_probe_available() {
603
+ command -v javac >/dev/null 2>&1
604
+ }
605
+
504
606
  gate13_java_sources() {
505
607
  git diff --cached --name-only --diff-filter=d -- '*.java' 2>/dev/null
506
608
  }
@@ -574,6 +676,23 @@ gate13_maven_injection_reliable() {
574
676
  return 1
575
677
  fi
576
678
 
679
+ # AC-670-33 (#659): every check above verifies that the arguments can be
680
+ # ASSEMBLED. None of them verifies that they take EFFECT, which is exactly how a
681
+ # pom declaring plugin 3.13.0 was declared "reliable" while Maven ran with no
682
+ # -Xlint at all and Gate 13 reported PASS on a file javac warned about 3 times.
683
+ #
684
+ # So prove it: one probe compile of a known-raw-type source through the same
685
+ # command shape. If the marker does not appear, injection did not happen and the
686
+ # caller must degrade -- "could not verify" is never "verified fine" (#499/#570).
687
+ if ! gate13_maven_probe_available; then
688
+ printf 'javac unavailable; -Xlint injection cannot be probed\n' >&2
689
+ return 1
690
+ fi
691
+ if ! gate13_maven_lint_effective "${MVN_BIN:-mvn}"; then
692
+ printf 'probe compile did not produce a [rawtypes] marker; -Xlint injection is NOT effective\n' >&2
693
+ return 1
694
+ fi
695
+
577
696
  return 0
578
697
  }
579
698
 
@@ -704,14 +823,20 @@ gate13_run_java_analysis() {
704
823
  fi
705
824
 
706
825
  # Count javac's lint output. Maven prefixes it with "[WARNING] ".
707
- mvn_findings=$(printf '%s\n' "$mvn_out" \
708
- | grep -cE '\[WARNING\].*\[(rawtypes|unchecked|deprecation|fallthrough|finally|cast|static|try|overrides|overloads|serial|synchronization|varargs|divzero|empty|exports|module|opens|options|path|processing|removal|requires|text-blocks|this-escape)\]' \
709
- | tr -d '[:space:]')
826
+ #
827
+ # #659: this used to be an English `[WARNING]...[allow-listed-category]`
828
+ # regex. Applied to a localised build -- or to a JDK category the list does
829
+ # not name -- it returned 0 on code javac had warned about, and the gate then
830
+ # printed PASSED. The shared counter below is marker- and locale-proof and
831
+ # fails closed (#499/#570). It lives in one place so the Maven path and the
832
+ # direct-javac path can never disagree about what counts as a finding.
833
+ mvn_findings=$(gate13_count_lint_findings "$mvn_out")
710
834
  mvn_findings=${mvn_findings:-0}
835
+ case "$mvn_findings" in ''|*[!0-9]*) mvn_findings=0 ;; esac
711
836
 
712
837
  if [ "$mvn_findings" -gt 0 ]; then
713
838
  printf '%s\n' "$mvn_out" \
714
- | grep -E '\[WARNING\].*\[[a-z-]+\]' | head -10 | sed 's/^/ /'
839
+ | grep -E '\[[a-z][a-z0-9-]*\]|warning:' | head -10 | sed 's/^/ /'
715
840
  echo " ❌ ${mvn_findings} javac -Xlint finding(s)"
716
841
  return "$GATE13_FAIL"
717
842
  fi
package/gate-4.sh CHANGED
@@ -46,7 +46,14 @@ java_coverage_note() {
46
46
  esac
47
47
  done
48
48
  [ -n "$_has_java" ] || return 0
49
- echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
49
+ # #628: `docs/` is not in the npm package's `files` list, so pointing a user at
50
+ # `docs/java-principles-coverage.md` was a dead path in every installed project --
51
+ # the note told them to read a file that does not exist on their machine. Cite the
52
+ # upstream URL instead, and keep the local path only when the doc is actually
53
+ # present (i.e. when running inside this repo).
54
+ local _where="https://github.com/boyingliu01/xp-gate/blob/main/docs/java-principles-coverage.md"
55
+ [ -f "docs/java-principles-coverage.md" ] && _where="docs/java-principles-coverage.md"
56
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see $_where)"
50
57
  }
51
58
 
52
59
  2>&1 echo ""
@@ -501,6 +501,108 @@ gate13_javac_args() {
501
501
  printf '%s\n' "-Xlint:all,-processing -d"
502
502
  }
503
503
 
504
+ # ---------------------------------------------------------------------------
505
+ # AC-670-34 / AC-670-35 (#659) — counting javac lint findings, locale-proof.
506
+ #
507
+ # The previous detector was an allow-list of English `[category]` names applied to
508
+ # `[WARNING]` lines. Two independent ways it returned 0 on defective code:
509
+ #
510
+ # 1. NOVELTY: a JDK adds a category the list does not name (`future-category`),
511
+ # or the fix relies on the list staying in sync with javac. A fixed allow-list
512
+ # over an open enum is a fail-OPEN design.
513
+ # 2. LOCALISATION: on a non-English build javac's own hint drifts, and the
514
+ # `[WARNING]` prefix may too. Measured on a zh_CN Maven build, the tell is
515
+ # "使用了未经检查或不安全的操作 ... 请使用 -Xlint:unchecked 重新编译" -- a message
516
+ # javac emits precisely when -Xlint was NOT in effect.
517
+ #
518
+ # Fail-closed policy (#499/#570) applied to text matching: count any bracketed
519
+ # lowercase category (whatever it is named), and treat javac's "you should have
520
+ # used -Xlint" hint as a finding too -- the code has an unchecked/unsafe operation
521
+ # whether or not the flag that would name it was on.
522
+ #
523
+ # Prints a single integer. Never fails (always exits 0).
524
+ # ---------------------------------------------------------------------------
525
+ gate13_count_lint_findings() {
526
+ local src="${1:-}"
527
+ [ -n "$src" ] || src=$(cat)
528
+ printf '%s\n' "$src" | awk '
529
+ BEGIN { n = 0 }
530
+ # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
+ # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
+ # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
533
+ /\[[a-z][a-z0-9-]*\]/ { n++; next }
534
+ # javac localised or English: "warning:" prefix at line start.
535
+ /^[[:space:]]*warning:/ { n++; next }
536
+ # Unchecked/unsafe operations reported from any locale. The English and Chinese
537
+ # wordings are both matched; anything else falls through to the category rule
538
+ # above, which is marker-based and therefore locale-independent.
539
+ /unchecked|unsafe|未经检查|不安全/ { n++; next }
540
+ END { print n + 0 }
541
+ '
542
+ return 0
543
+ }
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # AC-670-32 (#659) — PROVE that -Xlint takes effect, do not assume it.
547
+ #
548
+ # "Can assemble the flag" is not "the flag works". `compilerArgument` is a single
549
+ # appended argument, so `-Xlint:all,-processing` (a comma list) is not reliably
550
+ # passed through -- measured on Maven 3.9.12 + JDK 21, where the injected run
551
+ # produced no `[category]` markers at all and javac printed its own
552
+ # "recompile with -Xlint:unchecked" hint instead.
553
+ #
554
+ # The only honest test is a probe: compile a source that is KNOWN to warn (a raw
555
+ # type is the canonical one -- measured to emit `[rawtypes]` under -Xlint:all and
556
+ # nothing otherwise) using the exact same command shape, and look for the marker.
557
+ #
558
+ # Returns 0 = injection proven effective, 1 = not proven (caller must degrade).
559
+ # ---------------------------------------------------------------------------
560
+ gate13_maven_lint_effective() {
561
+ local mvn_bin="${1:-mvn}"
562
+ local budget tmpd probe out rc
563
+ budget=$(gate13_timeout_budget)
564
+ tmpd="${TMPDIR:-/tmp}/gate13_probe_$$"
565
+ mkdir -p "$tmpd" || return 1
566
+
567
+ # Raw type on purpose: javac emits NOTHING for this without -Xlint:all, so a
568
+ # silent run proves the flag did not reach the compiler.
569
+ probe="$tmpd/Gate13Probe.java"
570
+ cat > "$probe" <<'PROBE'
571
+ import java.util.ArrayList;
572
+ import java.util.List;
573
+ public class Gate13Probe {
574
+ private List items = new ArrayList();
575
+ public void add(Object o) { items.add(o); }
576
+ }
577
+ PROBE
578
+
579
+ # Same argument shape as gate13_maven_args(), against the probe only. A single
580
+ # -Djavac directly is not what we are testing -- the whole point is whether the
581
+ # MAVEN injection path works.
582
+ if command -v timeout >/dev/null 2>&1; then
583
+ out=$(timeout "$budget" javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
584
+ else
585
+ out=$(javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
586
+ fi
587
+ rc=$?
588
+ rm -rf "$tmpd"
589
+
590
+ # If the DIRECT javac probe cannot even produce a rawtypes warning, the JDK
591
+ # itself is not behaving as measured, so nothing about the Maven path can be
592
+ # concluded -- report "not proven" rather than "effective".
593
+ if [ "$rc" -ne 0 ]; then
594
+ return 1
595
+ fi
596
+ printf '%s\n' "$out" | grep -q 'rawtypes'
597
+ }
598
+
599
+ # Is `-Xlint` reachable at all on this machine? The Maven probe only makes sense
600
+ # when a raw-type warning is obtainable; when javac cannot produce one, callers
601
+ # stay in degraded mode instead of claiming a clean lint.
602
+ gate13_maven_probe_available() {
603
+ command -v javac >/dev/null 2>&1
604
+ }
605
+
504
606
  gate13_java_sources() {
505
607
  git diff --cached --name-only --diff-filter=d -- '*.java' 2>/dev/null
506
608
  }
@@ -574,6 +676,23 @@ gate13_maven_injection_reliable() {
574
676
  return 1
575
677
  fi
576
678
 
679
+ # AC-670-33 (#659): every check above verifies that the arguments can be
680
+ # ASSEMBLED. None of them verifies that they take EFFECT, which is exactly how a
681
+ # pom declaring plugin 3.13.0 was declared "reliable" while Maven ran with no
682
+ # -Xlint at all and Gate 13 reported PASS on a file javac warned about 3 times.
683
+ #
684
+ # So prove it: one probe compile of a known-raw-type source through the same
685
+ # command shape. If the marker does not appear, injection did not happen and the
686
+ # caller must degrade -- "could not verify" is never "verified fine" (#499/#570).
687
+ if ! gate13_maven_probe_available; then
688
+ printf 'javac unavailable; -Xlint injection cannot be probed\n' >&2
689
+ return 1
690
+ fi
691
+ if ! gate13_maven_lint_effective "${MVN_BIN:-mvn}"; then
692
+ printf 'probe compile did not produce a [rawtypes] marker; -Xlint injection is NOT effective\n' >&2
693
+ return 1
694
+ fi
695
+
577
696
  return 0
578
697
  }
579
698
 
@@ -704,14 +823,20 @@ gate13_run_java_analysis() {
704
823
  fi
705
824
 
706
825
  # Count javac's lint output. Maven prefixes it with "[WARNING] ".
707
- mvn_findings=$(printf '%s\n' "$mvn_out" \
708
- | grep -cE '\[WARNING\].*\[(rawtypes|unchecked|deprecation|fallthrough|finally|cast|static|try|overrides|overloads|serial|synchronization|varargs|divzero|empty|exports|module|opens|options|path|processing|removal|requires|text-blocks|this-escape)\]' \
709
- | tr -d '[:space:]')
826
+ #
827
+ # #659: this used to be an English `[WARNING]...[allow-listed-category]`
828
+ # regex. Applied to a localised build -- or to a JDK category the list does
829
+ # not name -- it returned 0 on code javac had warned about, and the gate then
830
+ # printed PASSED. The shared counter below is marker- and locale-proof and
831
+ # fails closed (#499/#570). It lives in one place so the Maven path and the
832
+ # direct-javac path can never disagree about what counts as a finding.
833
+ mvn_findings=$(gate13_count_lint_findings "$mvn_out")
710
834
  mvn_findings=${mvn_findings:-0}
835
+ case "$mvn_findings" in ''|*[!0-9]*) mvn_findings=0 ;; esac
711
836
 
712
837
  if [ "$mvn_findings" -gt 0 ]; then
713
838
  printf '%s\n' "$mvn_out" \
714
- | grep -E '\[WARNING\].*\[[a-z-]+\]' | head -10 | sed 's/^/ /'
839
+ | grep -E '\[[a-z][a-z0-9-]*\]|warning:' | head -10 | sed 's/^/ /'
715
840
  echo " ❌ ${mvn_findings} javac -Xlint finding(s)"
716
841
  return "$GATE13_FAIL"
717
842
  fi
package/hooks/gate-4.sh CHANGED
@@ -46,7 +46,14 @@ java_coverage_note() {
46
46
  esac
47
47
  done
48
48
  [ -n "$_has_java" ] || return 0
49
- echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
49
+ # #628: `docs/` is not in the npm package's `files` list, so pointing a user at
50
+ # `docs/java-principles-coverage.md` was a dead path in every installed project --
51
+ # the note told them to read a file that does not exist on their machine. Cite the
52
+ # upstream URL instead, and keep the local path only when the doc is actually
53
+ # present (i.e. when running inside this repo).
54
+ local _where="https://github.com/boyingliu01/xp-gate/blob/main/docs/java-principles-coverage.md"
55
+ [ -f "docs/java-principles-coverage.md" ] && _where="docs/java-principles-coverage.md"
56
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see $_where)"
50
57
  }
51
58
 
52
59
  2>&1 echo ""
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @test REQ-664
3
+ * @intent `setup-global` points `core.hooksPath` at `~/.config/xp-gate/hooks`, and
4
+ * that setting is GLOBAL — it governs every repository on the machine. So
5
+ * any third-party tool that writes hooks into `core.hooksPath` writes into
6
+ * a directory xp-gate owns. `git lfs install` does exactly this, and the
7
+ * measured result was four hooks xp-gate never installs appearing there:
8
+ *
9
+ * hook-module-resolution.sh lib/ post-checkout post-commit
10
+ * post-merge pre-commit pre-push
11
+ *
12
+ * Two failures followed:
13
+ *
14
+ * 1. `doctor` only checked pre-commit / pre-push / post-merge. The extra
15
+ * files were neither reported nor explained, so the user had no way to
16
+ * learn that a global install had acquired foreign hooks.
17
+ * 2. `uninstall` did `rmSync(GLOBAL_HOOKS_DIR, {recursive: true})` — the
18
+ * whole tree, including the lfs hooks someone else put there. The
19
+ * file already had `verifyFileOwnership()`; the `type: 'dir'` branch
20
+ * simply did not use it.
21
+ *
22
+ * Deleting another tool's files is the more serious of the two: the user
23
+ * loses a working git-lfs setup and has no idea which command did it. So
24
+ * uninstall must classify before it removes, and SAY what it found.
25
+ * @covers AC-670-40, AC-670-41, AC-670-42
26
+ */
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const os = require('os');
31
+ const path = require('path');
32
+
33
+ /** Hooks xp-gate itself installs; anything else in the dir is foreign. */
34
+ const XP_GATE_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
35
+
36
+ function freshRequire(relPath) {
37
+ for (const key of Object.keys(require.cache)) {
38
+ if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
39
+ delete require.cache[key];
40
+ }
41
+ }
42
+ return require(relPath);
43
+ }
44
+
45
+ /** A hooks directory shaped like the measured post-`git lfs install` state. */
46
+ function makeHooksDir() {
47
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-'));
48
+ for (const hook of XP_GATE_HOOKS) {
49
+ fs.writeFileSync(path.join(dir, hook), '#!/bin/sh\n# xp-gate\n');
50
+ }
51
+ // Foreign: two hooks git-lfs installs, plus its support file.
52
+ fs.writeFileSync(path.join(dir, 'post-checkout'), '#!/bin/sh\ngit lfs post-checkout "$@"\n');
53
+ fs.writeFileSync(path.join(dir, 'post-commit'), '#!/bin/sh\ngit lfs post-commit "$@"\n');
54
+ fs.writeFileSync(path.join(dir, 'hook-module-resolution.sh'), '#!/bin/sh\n');
55
+ return dir;
56
+ }
57
+
58
+ describe('foreign hooks in the global hooksPath (#664)', () => {
59
+ it('AC-670-40: the classifier names xp-gate hooks and flags everything else', () => {
60
+ const { classifyHooksDir } = freshRequire('../doctor');
61
+ expect(typeof classifyHooksDir).toBe('function');
62
+
63
+ const dir = makeHooksDir();
64
+ try {
65
+ const result = classifyHooksDir(dir);
66
+ expect(result.owned.sort()).toEqual([...XP_GATE_HOOKS].sort());
67
+ // Every foreign entry is reported, not silently dropped. `foreign` holds
68
+ // { name, source } records, so compare names.
69
+ const foreignNames = result.foreign.map((f) => f.name);
70
+ expect(foreignNames).toContain('post-checkout');
71
+ expect(foreignNames).toContain('post-commit');
72
+ // lib/ is xp-gate's own shared-library directory, not foreign.
73
+ expect(foreignNames).not.toContain('lib');
74
+ } finally {
75
+ fs.rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it('AC-670-41: git-lfs hooks are identified by source, unknown files stay generic', () => {
80
+ const { classifyHooksDir } = freshRequire('../doctor');
81
+ const dir = makeHooksDir();
82
+ try {
83
+ const result = classifyHooksDir(dir);
84
+ // Naming the likely source is what turns a confusing listing into an
85
+ // actionable message: "git lfs" tells the user which tool to look at.
86
+ const lfs = result.foreign.filter((f) => f.source === 'git-lfs').map((f) => f.name);
87
+ expect(lfs).toContain('post-checkout');
88
+ expect(lfs).toContain('post-commit');
89
+
90
+ // A file with no recognizable signature must still be reported, but without
91
+ // guessing a source it cannot prove.
92
+ fs.writeFileSync(path.join(dir, 'post-rebase'), '#!/bin/sh\necho mine\n');
93
+ const again = classifyHooksDir(dir);
94
+ const unknown = again.foreign.find((f) => f.name === 'post-rebase');
95
+ expect(unknown).toBeDefined();
96
+ expect(unknown.source).not.toBe('git-lfs');
97
+ } finally {
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ it('AC-670-42: uninstall refuses to delete a hooks dir holding foreign files', () => {
103
+ const { planHooksDirRemoval } = freshRequire('../uninstall');
104
+ expect(typeof planHooksDirRemoval).toBe('function');
105
+
106
+ const dir = makeHooksDir();
107
+ try {
108
+ const plan = planHooksDirRemoval(dir);
109
+ // Recursive removal would take git-lfs's files with it, so it must not be
110
+ // the chosen action while foreign content is present.
111
+ expect(plan.action).not.toBe('remove-all');
112
+ expect(plan.foreign.length).toBeGreaterThan(0);
113
+
114
+ // A directory holding ONLY xp-gate files is safe to remove wholesale.
115
+ const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-clean-'));
116
+ for (const hook of XP_GATE_HOOKS) {
117
+ fs.writeFileSync(path.join(clean, hook), '#!/bin/sh\n# xp-gate\n');
118
+ }
119
+ expect(planHooksDirRemoval(clean).action).toBe('remove-all');
120
+ fs.rmSync(clean, { recursive: true, force: true });
121
+ } finally {
122
+ fs.rmSync(dir, { recursive: true, force: true });
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @test REQ-663
3
+ * @intent `xp-gate uninstall` presents itself as the inverse of `init`, but it only
4
+ * removed `hooks/` + `adapters/` + `core.hooksPath`. After
5
+ * `setup-global` then `uninstall --global --force`, the tool printed
6
+ * "Uninstall complete" while leaving behind ~1000 files:
7
+ *
8
+ * ~/.config/xp-gate/modules/ principles/ mutation/ mock-policy/
9
+ * ~/.config/xp-gate/node_modules/ js-yaml / zod / argparse ...
10
+ * ~/.config/xp-gate/package.json
11
+ * ~/.config/xp-gate/package-lock.json
12
+ * ~/.config/xp-gate/xp-gate.json manifest + installedSkills record
13
+ * ~/.xp-gate/version-cache.json
14
+ *
15
+ * And skills were never in scope at all: 12 skills stayed in
16
+ * ~/.qoder/skills/ and ~/.config/opencode/skills/, which the next `init`
17
+ * then treated as already-installed.
18
+ *
19
+ * The residue is not cosmetic. A leftover `node_modules/` makes the NEXT
20
+ * `setup-global` skip its install ("module runtime deps → ... node_modules"
21
+ * reuses whatever is there), so a stale dependency tree survives an
22
+ * upgrade; and a leftover `xp-gate.json` carries the previous version's
23
+ * `manifest` and `lastInit`, which `doctor` then reports as the CURRENT
24
+ * installation.
25
+ *
26
+ * Two rules the fix must respect, both from the issue:
27
+ * - a user config (`.delphi-config.json`) is user DATA, not residue --
28
+ * keep it, but SAY SO instead of leaving it silently;
29
+ * - never delete a skill directory the user has edited. Only remove one
30
+ * whose content still matches the bundled copy.
31
+ * @covers AC-670-37, AC-670-38, AC-670-39
32
+ */
33
+ 'use strict';
34
+
35
+ // describe/it/expect/beforeEach/afterEach are vitest globals in this repo's config;
36
+ // requiring them from 'vitest' fails because these tests are CommonJS.
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+
41
+ /** Build a throwaway HOME so the plan is computed against a controlled tree. */
42
+ function makeHome() {
43
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-uninst-'));
44
+ return home;
45
+ }
46
+
47
+ /**
48
+ * Reproduce the on-disk shape `xp-gate setup-global` leaves behind, including the
49
+ * parts that were measured as residue in #663.
50
+ */
51
+ function seedGlobalInstall() {
52
+ const configDir = path.join(process.env.HOME, '.config', 'xp-gate');
53
+ for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
54
+ fs.mkdirSync(path.join(configDir, rel), { recursive: true });
55
+ fs.writeFileSync(path.join(configDir, rel, 'index.js'), 'module.exports = {};\n');
56
+ }
57
+ for (const file of ['package.json', 'package-lock.json', 'xp-gate.json']) {
58
+ fs.writeFileSync(path.join(configDir, file), '{}\n');
59
+ }
60
+ // User data: must survive, and must be reported.
61
+ fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{}\n');
62
+ }
63
+
64
+
65
+ describe('uninstall --global plans the whole installation, not just hooks (#663)', () => {
66
+ let home;
67
+ const savedEnv = {};
68
+
69
+ beforeEach(() => {
70
+ home = makeHome();
71
+ savedEnv.HOME = process.env.HOME;
72
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
73
+ process.env.HOME = home;
74
+ process.env.USERPROFILE = home;
75
+ });
76
+
77
+ afterEach(() => {
78
+ process.env.HOME = savedEnv.HOME;
79
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
80
+ fs.rmSync(home, { recursive: true, force: true });
81
+ });
82
+
83
+ /**
84
+ * Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
85
+ * time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
86
+ */
87
+ function freshRequire(relPath) {
88
+ for (const key of Object.keys(require.cache)) {
89
+ if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
90
+ delete require.cache[key];
91
+ }
92
+ }
93
+ return require(relPath);
94
+ }
95
+
96
+ it('AC-670-37: modules, node_modules and the manifest files are all in scope', () => {
97
+ const { buildPlan } = freshRequire('../uninstall');
98
+ expect(typeof buildPlan).toBe('function');
99
+
100
+ // buildPlan only schedules what actually exists, so the fixture has to look
101
+ // like a real `setup-global` install -- otherwise this asserts nothing.
102
+ seedGlobalInstall();
103
+
104
+ const plan = buildPlan('global');
105
+ const paths = plan.filter((step) => step.path).map((step) => step.path.split(path.sep).join('/'));
106
+
107
+ // Each of these was measured residue. A plan that omits any of them reports
108
+ // "complete" over an installation that is still there.
109
+ for (const required of ['modules', 'node_modules', 'package.json', 'package-lock.json', 'xp-gate.json']) {
110
+ expect(
111
+ paths.some((p) => p.endsWith(required)),
112
+ `${required} is missing from the uninstall plan (measured residue, #663)`,
113
+ ).toBe(true);
114
+ }
115
+ });
116
+
117
+ it('AC-670-38: user config is retained and reported, never silently kept', () => {
118
+ const { buildPlan } = freshRequire('../uninstall');
119
+ seedGlobalInstall();
120
+ const plan = buildPlan('global');
121
+ // Deletion steps only: a RETAIN step names the file precisely because it is
122
+ // being kept, so including it here would assert the opposite of the intent.
123
+ const deletedPaths = plan
124
+ .filter((step) => step.path && step.retain !== true && step.type !== 'retain')
125
+ .map((step) => step.path.split(path.sep).join('/'));
126
+
127
+ // `.delphi-config.json` holds the user's expert/model choice -- it is data, so
128
+ // it must appear in no deletion step. (The --purge branch is a separate,
129
+ // explicitly destructive path and is not what this plan describes.)
130
+ expect(deletedPaths.some((p) => p.endsWith('.delphi-config.json'))).toBe(false);
131
+ // Not deleting it is only half the fix: the summary has to name it.
132
+ const retained = plan.filter((step) => step.retain === true);
133
+ expect(retained.length, 'no step declares a retention, so the summary cannot report one').toBeGreaterThan(0);
134
+ });
135
+
136
+ it('AC-670-39: an edited skill directory is never scheduled for deletion', () => {
137
+ const { planSkillRemovals } = freshRequire('../uninstall');
138
+ expect(typeof planSkillRemovals).toBe('function');
139
+
140
+ const bundled = path.join(home, 'bundle', 'skills');
141
+ const installedRoot = path.join(home, '.qoder', 'skills');
142
+ for (const [dir, body] of [
143
+ [path.join(bundled, 'clean-skill'), 'bundled body\n'],
144
+ [path.join(installedRoot, 'clean-skill'), 'bundled body\n'],
145
+ [path.join(bundled, 'edited-skill'), 'bundled body\n'],
146
+ [path.join(installedRoot, 'edited-skill'), 'bundled body\n// user edit\n'],
147
+ ]) {
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), body);
150
+ }
151
+
152
+ const removals = planSkillRemovals(['clean-skill', 'edited-skill'], installedRoot, bundled);
153
+ const names = removals.map((r) => path.basename(r.path));
154
+
155
+ // Untouched -> remove. This is the residue the issue is about.
156
+ expect(names).toContain('clean-skill');
157
+ // Edited -> keep. Deleting a user's modifications is worse than residue.
158
+ expect(names).not.toContain('edited-skill');
159
+ });
160
+ });