@boyingliu01/xp-gate 0.31.4 → 0.32.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 (144) 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 +65 -16
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +65 -16
  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 +101 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +259 -57
  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-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/hooks/gate-3.sh CHANGED
@@ -329,27 +329,62 @@ else
329
329
  # is reverted at the next publish, so grading it cannot be acted
330
330
  # on (doctorDelphi CCN=106 blocked every src/npm-package commit,
331
331
  # on HEAD too — no baseline entry could ever exist for it).
332
- # test __tests__/, *.test.*, *_test.{py,go}, *Test(s).java — recorded
333
- # in the report's test bucket, never in the baseline.
332
+ # test test dirs (tests/, test/, __tests__/), *.test.*, test_*.py,
333
+ # *_test.{py,go}, *Test(s).java — recorded in the report's test
334
+ # bucket, never in the baseline.
334
335
  # Both prefixes and the test pattern mirror ccn-report.cjs so the gate and
335
336
  # the writer cannot drift apart again (pinned by __tests__/gate-3-*.bats).
337
+ #
338
+ # #634: pytest's default is `python_files = test_*.py` under a `tests/`
339
+ # root. The previous pattern only knew the SUFFIX form `_test.{py,go}` and
340
+ # `*.test.*`, so a pytest-laid-out project had every test module graded as
341
+ # production source: test-helper complexity was reported as source debt and
342
+ # the ratchet could block on files the baseline deliberately never records
343
+ # (bucket === 'test').
344
+ # `(^|/)(tests?)/` — a tests/ or test/ directory anywhere in the tree
345
+ # `(^|/)test_[^/]*\.py$` — pytest default filenames at any depth
346
+ # Both are anchored with (^|/) so `latest/`, `contest/`, `attest/` and
347
+ # `my_test_dir/` are NOT caught (a leading `_?test_.*\.py$` would have
348
+ # swallowed `src/my_test_dir/bar.py`).
349
+ # Must stay equivalent to TEST_PATH in scripts/ccn-report.cjs.
336
350
  CC_FILES=$(echo "$CC_FILES" | grep -vE \
337
- '^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests__?/|\.test\.[cm]?[jt]sx?$|_test\.(py|go)$|Test(s)?\.java$' || true)
351
+ '^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests?__/|(^|/)(tests?)/|\.test\.[cm]?[jt]sx?$|(^|/)test_[^/]*\.py$|_test\.(py|go)$|Test(s)?\.java$' || true)
338
352
 
339
353
  if [ -n "$CC_FILES" ]; then
340
354
  echo "Checking complexity for source files..."
341
355
 
342
- # Run lizard with CCN threshold
343
- # One path per line, into an array. Unquoted $CC_FILES relied on word
344
- # splitting, which also splits any path containing a space -- handing
345
- # lizard two bogus paths and checking neither (#457 defect class, same
346
- # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
347
- # collapse the list into one argument. An array does both.
348
- CC_FILES_ARGS=()
349
- while IFS= read -r _cc_file; do
350
- [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
351
- done <<< "$CC_FILES"
352
- CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
356
+ # Run lizard in chunks. Two reasons, both load-bearing:
357
+ # - One path per line, into an array. Unquoted $CC_FILES relied on word
358
+ # splitting, which also splits any path containing a space -- handing
359
+ # lizard two bogus paths and checking neither (#457 defect class, same
360
+ # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
361
+ # collapse the list into one argument. An array does both.
362
+ # - Chunked instead of one argv. Windows caps a single command line at
363
+ # 32,767 characters, and under `--all` the population is the whole tree
364
+ # (1,000+ sources on a mid-sized repo). The oversized argv never reached
365
+ # lizard, nothing was printed, and the gate graded an empty CSV as if the
366
+ # tree were clean. XP_GATE_LIZARD_ARGV_BUDGET lets a probe force a split
367
+ # without needing a huge fixture.
368
+ LIZARD_ARGV_BUDGET="${XP_GATE_LIZARD_ARGV_BUDGET:-8000}"
369
+ lizard_batches() {
370
+ # stdin: one path per line. stdout: lizard output, batches concatenated.
371
+ local _batch=() _size=0 _path
372
+ while IFS= read -r _path; do
373
+ [ -n "$_path" ] || continue
374
+ if [ "${#_batch[@]}" -gt 0 ] && [ $((_size + ${#_path} + 1)) -gt "$LIZARD_ARGV_BUDGET" ]; then
375
+ "$LIZARD_PATH" "$@" "${_batch[@]}" 2>/dev/null || true
376
+ _batch=()
377
+ _size=0
378
+ fi
379
+ _batch+=("$_path")
380
+ _size=$((_size + ${#_path} + 1))
381
+ done
382
+ if [ "${#_batch[@]}" -gt 0 ]; then
383
+ "$LIZARD_PATH" "$@" "${_batch[@]}" 2>/dev/null || true
384
+ fi
385
+ return 0
386
+ }
387
+ CC_OUTPUT=$(printf '%s\n' "$CC_FILES" | lizard_batches -C "$CCN_THRESHOLD" 2>&1)
353
388
 
354
389
  # REQ-D (sprint-20261018-quality): baseline ratchet. When
355
390
  # .ccn-baseline.json exists it replaces the flat threshold verdict:
@@ -366,7 +401,7 @@ else
366
401
  # Route the CSV through the same verifier; on rows it cannot verify the
367
402
  # helper exits non-zero, and this gate fails closed instead of passing
368
403
  # an unvalidated number.
369
- CC_CSV=$("$LIZARD_PATH" --csv ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
404
+ CC_CSV=$(printf '%s\n' "$CC_FILES" | lizard_batches --csv)
370
405
  CC_REPORT="$(git rev-parse --show-toplevel 2>/dev/null)/scripts/ccn-report.cjs"
371
406
  # Fail closed when the verifier is unavailable. Previously this fell
372
407
  # through and graded the RAW lizard CSV, while CI hard-fails on the same
@@ -392,6 +427,20 @@ else
392
427
  fi
393
428
  RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
394
429
  [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
430
+ # Fail closed when the ratchet library is missing. Sourcing a nonexistent
431
+ # file only prints "No such file or directory" and leaves
432
+ # evaluate_ccn_ratchet undefined; the verdict then came back empty, every
433
+ # counter read 0 and the gate printed "✅ PASSED" — a CCN regression graded
434
+ # green with nothing compared. The installer never created this path
435
+ # (copyAdapters skipped hooks/lib), so this was the normal state after a
436
+ # global install, not an exotic one.
437
+ if [ ! -f "$RATCHET_LIB" ]; then
438
+ echo "❌ BLOCKED - complexity ratchet library unavailable (ccn-ratchet.sh)."
439
+ echo "Expected at \$GATE_DIR/lib/ccn-ratchet.sh or <repo>/githooks/lib/ccn-ratchet.sh."
440
+ echo "Reinstall with 'xp-gate init --global' (or 'xp-gate update-hooks') to restore it."
441
+ GATE_3_DETAIL="mode=ratchet ratchet=MISSING"
442
+ exit 1
443
+ fi
395
444
  # shellcheck source=/dev/null
396
445
  . "$RATCHET_LIB"
397
446
  # NOTE: the thresholds must be plain assignments BEFORE the pipe. As a
@@ -436,7 +485,7 @@ else
436
485
  # file,name,signature,...), so the verdict comes from the same numbers
437
486
  # lizard uses, independent of version and table formatting. --csv here is
438
487
  # the flat-threshold run; the ratchet branch above builds its own.
439
- CC_CSV_FLAT=$("$LIZARD_PATH" --csv -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
488
+ CC_CSV_FLAT=$(printf '%s\n' "$CC_FILES" | lizard_batches --csv -C "$CCN_THRESHOLD")
440
489
  CC_WARNINGS=$(printf '%s\n' "$CC_CSV_FLAT" | awk -F, -v t="$CCN_THRESHOLD" '
441
490
  NF > 1 && $2 + 0 > t + 0 { c++ }
442
491
  END { print c + 0 }
@@ -56,9 +56,25 @@ enforce_python_coverage() {
56
56
  return 0
57
57
  fi
58
58
 
59
+ # #636: this probe is the SAME fail-open #570 closed, only earlier. Returning 0
60
+ # here means "no coverage.py on PATH" silently disables the Python coverage
61
+ # floor — and unlike a deleted .coverage file, a missing tool is a steady state,
62
+ # not an accident: every consumer repo / CI image / colleague machine without
63
+ # coverage.py had this gate switched off behind a ⚠️ that reads as informational.
64
+ # The #570 suite could not see it because its setup installs a coverage stub
65
+ # precisely so this probe succeeds.
66
+ #
67
+ # A subset run stays the one exception: nothing was measured because only part
68
+ # of the suite ran, so there is no total to compare.
59
69
  if ! command -v coverage &> /dev/null; then
60
- echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
61
- return 0
70
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
71
+ echo "⚠️ coverage.py not available (subset test run) — not blocking."
72
+ return 0
73
+ fi
74
+ echo "❌ BLOCKED - Python coverage could not be measured: coverage.py is not on PATH,"
75
+ echo " so the ${COVERAGE_THRESHOLD:-80}% floor cannot be verified."
76
+ echo " Install it (pip install coverage) and commit again."
77
+ return 1
62
78
  fi
63
79
 
64
80
  COVERAGE_PERCENT=""
@@ -0,0 +1,251 @@
1
+ #!/bin/bash
2
+ # Java smart test selection (#640) — the Java counterpart of the TypeScript
3
+ # selector (#286) and the Python selector (#399).
4
+ #
5
+ # Why: the Java arm of Gate 5 ran the FULL Maven/Gradle suite on every commit.
6
+ # Measured on a 206-file / 1079-test project: `mvn test jacoco:report` takes
7
+ # 77.8s no matter whether one file or a hundred changed. TypeScript and Python
8
+ # have had three-tier selection for a long time; Java had no equivalent — the
9
+ # `PARTIAL_TEST_RUN` capability existed but nothing on the Java path ever set
10
+ # it, so the downgrade channel was dead code for that language.
11
+ #
12
+ # Kept in its own file so the strategy truth table and the related-test lookup
13
+ # are unit-testable without executing the hook
14
+ # (githooks/__tests__/issue-640-java-test-selection.test.bats).
15
+ #
16
+ # Three tiers, matching the TS/Python implementations:
17
+ # 1. changed-tests — only test files changed: run exactly those.
18
+ # 2. related — source files changed, no test files: run the tests the
19
+ # path convention maps to them.
20
+ # 3. full — >20 test files changed, a build manifest changed, or no
21
+ # related tests found: run everything with the real
22
+ # coverage threshold.
23
+ # Tier 1/2 run WITHOUT the global coverage threshold (subset coverage is
24
+ # meaningless globally); the caller sets PARTIAL_TEST_RUN so Gate 5 warns
25
+ # instead of blocking.
26
+
27
+ # Max changed test files before falling back to the full suite. Same value as
28
+ # the TypeScript (#286) and Python (#399) selectors so all three languages
29
+ # behave alike.
30
+ JAVA_MAX_CHANGED_TESTS=20
31
+
32
+ # Build manifests: a change here can break ANY test (dependency bump, surefire
33
+ # config, compiler level), so the selector always escalates to the full suite.
34
+ JAVA_DEPENDENCY_PATTERNS='(^|/)(pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|gradle/wrapper/[^/]+|mvnw|mvnw\.cmd|\.mvn/[^/]+)$'
35
+
36
+ # Java test CLASS-name conventions (applied to the basename only).
37
+ #
38
+ # Maven surefire's own defaults are `*Test.java`, `Test*.java`, `*Tests.java`,
39
+ # `*TestCase.java`; Gradle users commonly add `*Spec.java` and `*IT.java`.
40
+ #
41
+ # The `Test[A-Z]\w*` form is deliberately narrowed to `Test[A-Z]` followed by
42
+ # something that looks like a test subject rather than a helper: bare
43
+ # `Test[^/]*` would swallow `TestHelper.java`, `TestUtils.java`,
44
+ # `TestFixtures.java` — production/support classes that happen to start with
45
+ # "Test". Misclassifying a SOURCE as a TEST is the dangerous direction: those
46
+ # helpers are real code whose changing should run `related`, and calling them
47
+ # tests would make the selector run them as if they were suites (Surefire
48
+ # would then find no tests in them) and skip the source-driven lookup.
49
+ #
50
+ # A path under a test source root is caught independently by
51
+ # java_is_test_file, so helpers living in src/test/java are still tests.
52
+ JAVA_TEST_FILE_RE='(^|/)(Test[A-Z]\w*(Test|Tests|Case|Spec|IT)|[A-Z]\w*Test|[A-Z]\w*Tests|[A-Z]\w*TestCase|[A-Z]\w*Spec|[A-Z]\w*IT)\.java$'
53
+
54
+ # Decide the strategy from three counts. Pure function — no filesystem access,
55
+ # so the truth table is exhaustive and cheap to test.
56
+ #
57
+ # $1 test_count changed test files
58
+ # $2 src_count changed non-test Java source files
59
+ # $3 deps_changed 1 when a build manifest changed, else 0
60
+ #
61
+ # Echoes exactly one of: full | changed-tests | related | skip
62
+ select_java_test_strategy() {
63
+ # `${1-0}`, not `${1:-0}`: the `:-` form rewrites an EMPTY argument to "0",
64
+ # which would make the empty check below unreachable — a caller passing ""
65
+ # (e.g. an unset count that reached us as an empty string) would silently
66
+ # mean "0 changed files" and skip the suite. Only a genuinely UNSET argument
67
+ # defaults to 0.
68
+ local test_count="${1-0}"
69
+ local src_count="${2-0}"
70
+ local deps_changed="${3-0}"
71
+
72
+ # Non-numeric OR empty input must not silently become "0" and skip the
73
+ # suite: a caller bug there would run nothing and report PASS. Escalate.
74
+ case "$test_count" in
75
+ ''|*[!0-9]*) printf '%s' "full"; return 0 ;;
76
+ esac
77
+ case "$src_count" in
78
+ ''|*[!0-9]*) printf '%s' "full"; return 0 ;;
79
+ esac
80
+
81
+ # Dependency/build changes can break anything -> always the full suite.
82
+ if [ "$deps_changed" = "1" ]; then
83
+ printf '%s' "full"
84
+ return 0
85
+ fi
86
+
87
+ # Large batch: per-file selection loses its point, and the other two
88
+ # language arms use the same cutoff.
89
+ if [ "$test_count" -gt "$JAVA_MAX_CHANGED_TESTS" ]; then
90
+ printf '%s' "full"
91
+ return 0
92
+ fi
93
+
94
+ if [ "$test_count" -gt 0 ]; then
95
+ printf '%s' "changed-tests"
96
+ return 0
97
+ fi
98
+
99
+ if [ "$src_count" -gt 0 ]; then
100
+ printf '%s' "related"
101
+ return 0
102
+ fi
103
+
104
+ printf '%s' "skip"
105
+ }
106
+
107
+ # True (0) when the path looks like a Java test source.
108
+ #
109
+ # $1 path, e.g. src/test/java/com/acme/FooTest.java
110
+ java_is_test_file() {
111
+ local path="$1"
112
+ [ -n "$path" ] || return 1
113
+ # A path under a test source root is a test even when the basename does not
114
+ # end in Test/Tests/Spec — surefire includes are configurable, and a `*IT.java`
115
+ # integration test living in src/test/java must not be mistaken for source.
116
+ #
117
+ # The leading "/" matters: git reports repo-relative paths with no leading
118
+ # slash ("src/test/java/..."), so a bare "*/src/test/*" pattern would never
119
+ # match the most common case of all. Prefixing normalises both forms.
120
+ case "/$path" in
121
+ */src/test/java/*|*/src/test/*|*/src/androidTest/*) return 0 ;;
122
+ esac
123
+ printf '%s' "$path" | grep -qE "$JAVA_TEST_FILE_RE"
124
+ }
125
+
126
+ # True (0) when any of the given paths is a Java build manifest.
127
+ java_dependency_changed() {
128
+ local path
129
+ for path in "$@"; do
130
+ [ -n "$path" ] || continue
131
+ if echo "$path" | grep -qE "$JAVA_DEPENDENCY_PATTERNS"; then
132
+ return 0
133
+ fi
134
+ done
135
+ return 1
136
+ }
137
+
138
+ # Map a Maven/Gradle source path to the test path(s) the convention implies.
139
+ # Echoes one existing path per line, deduplicated, in discovery order. Echoes
140
+ # nothing when no candidate exists — the caller then falls back to tier 3.
141
+ #
142
+ # $1 source path, e.g. src/main/java/com/acme/service/Foo.java
143
+ #
144
+ # The mapping mirrors the standard layout: src/main/java/<pkg>/X.java ->
145
+ # src/test/java/<pkg>/XTest.java. Multi-module builds are handled because the
146
+ # module prefix is preserved verbatim (only `main`->`test` inside the path is
147
+ # rewritten).
148
+ java_related_tests() {
149
+ local src_file="$1"
150
+ [ -n "$src_file" ] || return 0
151
+
152
+ local filename dir base
153
+ base="${src_file%.java}"
154
+ filename="${base##*/}"
155
+ dir="$(dirname "$src_file")"
156
+
157
+ local candidates=""
158
+ # Primary convention: same package, main -> test.
159
+ # "/$dir" for the same reason as java_is_test_file: git's repo-relative paths
160
+ # have no leading slash, so a bare "*/src/main/java/*" would never match.
161
+ case "/$dir" in
162
+ */src/main/java/*)
163
+ # Rewrite the source root segment. `#` as the sed delimiter keeps the
164
+ # pattern readable: / would need escaping in both the pattern AND the
165
+ # replacement. The leading "/" is prepended BEFORE the substitution and
166
+ # stripped after: the pattern is anchored on "/src/main/java/", and a
167
+ # repo-relative dir ("src/main/java/com/acme") does not contain that
168
+ # leading slash, so a bare substitution would match nothing and every
169
+ # "related" lookup would return empty — quietly downgrading every commit
170
+ # to the full suite (correct, but pointless).
171
+ local test_dir
172
+ test_dir=$(printf '/%s' "$dir" | sed 's#/src/main/java/#/src/test/java/#')
173
+ test_dir="${test_dir#/}"
174
+ candidates="$candidates
175
+ ${test_dir}/${filename}Test.java
176
+ ${test_dir}/${filename}Tests.java
177
+ ${test_dir}/${filename}Spec.java
178
+ ${test_dir}/Test${filename}.java"
179
+ ;;
180
+ esac
181
+ # Colocated forms (some projects keep tests beside sources).
182
+ candidates="$candidates
183
+ ${dir}/${filename}Test.java
184
+ ${dir}/${filename}Tests.java
185
+ ${dir}/${filename}Spec.java
186
+ ${dir}/__tests__/${filename}Test.java"
187
+
188
+ local seen=""
189
+ local candidate
190
+ # IFS-safe iteration over newline-separated candidates.
191
+ while IFS= read -r candidate; do
192
+ [ -n "$candidate" ] || continue
193
+ [ -f "$candidate" ] || continue
194
+ # Dedupe: several patterns resolve to the same file whenever the source
195
+ # already sits under a test-looking root.
196
+ case "$seen" in
197
+ *"|$candidate|"*) continue ;;
198
+ esac
199
+ seen="$seen|$candidate|"
200
+ printf '%s\n' "$candidate"
201
+ done <<EOF
202
+ $candidates
203
+ EOF
204
+ }
205
+
206
+ # Turn test file paths into the Maven `-Dtest=` selector list.
207
+ # Echoes one comma-separated argument, or nothing when the input is empty.
208
+ #
209
+ # $1..$N test file paths
210
+ #
211
+ # Maven matches `-Dtest=` against simple class names (not paths), so the
212
+ # directory part is dropped and the `.java` suffix stripped. Duplicates are
213
+ # removed: `-Dtest=A,A` makes Surefire run A twice.
214
+ java_maven_test_selector() {
215
+ local seen=" " out=""
216
+ local path name
217
+ for path in "$@"; do
218
+ [ -n "$path" ] || continue
219
+ name="${path##*/}"
220
+ name="${name%.java}"
221
+ [ -n "$name" ] || continue
222
+ case "$seen" in
223
+ *" $name "*) continue ;;
224
+ esac
225
+ seen="$seen$name "
226
+ if [ -z "$out" ]; then out="$name"; else out="$out,$name"; fi
227
+ done
228
+ [ -n "$out" ] && printf '%s' "$out"
229
+ return 0
230
+ }
231
+
232
+ # Turn test file paths into Gradle `--tests` filter arguments.
233
+ # Echoes one argument per line (Gradle wants a repeated flag, not a list).
234
+ #
235
+ # $1..$N test file paths
236
+ java_gradle_test_filters() {
237
+ local seen=""
238
+ local path name
239
+ for path in "$@"; do
240
+ [ -n "$path" ] || continue
241
+ name="${path##*/}"
242
+ name="${name%.java}"
243
+ [ -n "$name" ] || continue
244
+ case "$seen" in
245
+ *"|$name|"*) continue ;;
246
+ esac
247
+ seen="$seen|$name|"
248
+ printf '%s\n' "$name"
249
+ done
250
+ return 0
251
+ }
@@ -26,6 +26,55 @@
26
26
  # than before the fix.
27
27
  JSCPD_DEFAULT_IGNORE_PATTERNS='(^|/)(node_modules|dist|build|target|coverage|out|\.next|\.nuxt|vendor)/|\.log$|\.txt$|\.(ya?ml)$|golden|baseline\.json$|evidence'
28
28
 
29
+ # The BUILD-OUTPUT half of the set above, alone (#649).
30
+ #
31
+ # The combined set answers "is this worth comparing for duplication", which is a
32
+ # question only Gate 2 asks. Filtering file list inputs with the whole set also
33
+ # drops *.yaml, *.yml, *.txt and *.log -- and those are perfectly real project
34
+ # files everywhere else. Doing that broke a live test on the first attempt:
35
+ # `YAML staged files activate lint and IaC but skip SAST` expects a staged
36
+ # infra/deployment.yaml to count as a code change, and whole-set filtering made
37
+ # the hook announce "No code files changed, skipping lint" for a commit that
38
+ # added a deployment manifest.
39
+ #
40
+ # So the two notions are separated here rather than shared:
41
+ #
42
+ # BUILD OUTPUT (this) - a directory tree that is generated or vendored:
43
+ # not our code for ANY gate. Used to filter file lists.
44
+ # NON-REFACTORABLE (the combined set) - files whose duplication is data shape:
45
+ # still project files, just not duplication candidates.
46
+ # Gate 2 only.
47
+ #
48
+ # Same directory names, one definition; a directory added to the build-output
49
+ # list reaches every gate at once, which is the point of #649.
50
+ # Directory names that are generated or vendored in any mainstream toolchain.
51
+ #
52
+ # The second group (caches, virtualenvs, Python bytecode) is here because the
53
+ # first version of this list did not have it and the same class of bug was one
54
+ # `git add -A` away: __pycache__ is written by CPython itself and .pytest_cache
55
+ # by pytest, so a Python project that commits its workspace stages .pyc-adjacent
56
+ # artefacts and every scanner would read them as source. None of these can hold
57
+ # hand-written project code.
58
+ #
59
+ # NOT included on purpose: bin/ and obj/ (real source trees in plenty of
60
+ # projects -- e.g. cmd/bin layout, C# projects where obj/ IS output but bin/ is
61
+ # often committed scripts) and .venv/venv (genuinely ambiguous: some projects
62
+ # keep a small committed venv for reproducibility). Adding a name here removes
63
+ # it from EVERY gate at once, so the list stays the names that are unambiguous.
64
+ JSCPD_BUILD_OUTPUT_PATTERNS='(^|/)(node_modules|dist|build|target|coverage|out|\.next|\.nuxt|vendor|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|\.turbo|\.cache)/'
65
+
66
+ # Is this path inside a dependency tree or a build-output directory?
67
+ #
68
+ # XP_GATE_JSCPD_DEFAULT_IGNORE=0 still disables it, so the escape hatch keeps
69
+ # covering "some project really does want its vendor/ tree linted".
70
+ jscpd_is_build_output() {
71
+ local _path="$1"
72
+ if [ "${XP_GATE_JSCPD_DEFAULT_IGNORE:-1}" = "0" ]; then
73
+ return 1
74
+ fi
75
+ printf '%s\n' "$_path" | grep -qE "$JSCPD_BUILD_OUTPUT_PATTERNS"
76
+ }
77
+
29
78
  # Human-readable form, printed by the gate so the baseline is auditable (#591
30
79
  # point 3). Silence about the default is the same defect as silence about a
31
80
  # skipped gate: the user cannot answer "what did it scan".
@@ -38,6 +87,15 @@ jscpd_default_ignore_patterns() {
38
87
  # XP_GATE_JSCPD_DEFAULT_IGNORE=0 disables the set entirely: a project that
39
88
  # genuinely wants YAML compared must be able to say so, or the default would make
40
89
  # some real duplicates unreachable.
90
+ #
91
+ # SCOPE CHANGED IN #648: Gate 1's debugger check now calls this too, so the
92
+ # switch governs "what counts as our code" for BOTH gates — not just jscpd.
93
+ # Setting it to 0 makes Gate 1 scan dependencies and build output for debugger
94
+ # statements as well, which is rarely what someone disabling duplicate detection
95
+ # intends. Kept as one switch deliberately: two gates disagreeing about what a
96
+ # build artifact is would be its own defect, and a project that vendors code
97
+ # wants it treated consistently everywhere. Override per-run rather than
98
+ # permanently if you only meant to widen Gate 2's scan.
41
99
  jscpd_is_default_ignored() {
42
100
  local _path="$1"
43
101
  if [ "${XP_GATE_JSCPD_DEFAULT_IGNORE:-1}" = "0" ]; then
@@ -46,6 +104,49 @@ jscpd_is_default_ignored() {
46
104
  printf '%s\n' "$_path" | grep -qE "$JSCPD_DEFAULT_IGNORE_PATTERNS"
47
105
  }
48
106
 
107
+ # Drop dependency and build-output paths from a newline-separated file list on
108
+ # stdin, echoing what remains (#649).
109
+ #
110
+ # Every scanner that builds its own list from `git diff --cached` used to
111
+ # re-derive the exclusions by hand, and they drifted. The test-pairing check
112
+ # excluded node_modules/.next/.nuxt/dist/build/.turbo/.cache but not vendor,
113
+ # target, coverage or out -- so a staged `vendor/x.ts` was reported as a new
114
+ # source file with no test. Six more sites had no exclusions at all. The one
115
+ # place that got a shared ignore set (Gate 2) is the one that stopped having
116
+ # this bug.
117
+ #
118
+ # This is the shared answer: callers pipe their staged list through it and stop
119
+ # maintaining their own list. It reads stdin and writes stdout so it composes
120
+ # with whatever selection the caller already did:
121
+ #
122
+ # NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A \
123
+ # | filter_project_paths | grep -E '\.(ts|tsx)$' || true)
124
+ #
125
+ # Deliberately a filter, not a generator: a caller that needs a different git
126
+ # query (only-added, only-modified) keeps it, and only the exclusion rule is
127
+ # shared. Fail-open on empty input so `|| true` pipelines stay quiet.
128
+ #
129
+ # ON THE COUPLING (Delphi round 1, MC-01/FC-01): yes, this makes Gate 2's
130
+ # ignore set load-bearing for every gate. Widening it now widens what every
131
+ # scanner skips. That risk is accepted deliberately, and the alternative is
132
+ # worse: the bug this fixes WAS a set of per-scanner definitions that drifted
133
+ # apart, so N independent sets would reproduce it N times. The guard against
134
+ # silent widening is that every change to the set lands in one reviewable
135
+ # place, and #649's tests pin the boundary cases (vendor/target/coverage/out
136
+ # ignored; src/lib/docs kept). If a gate ever needs a genuinely different
137
+ # notion of "our code", introduce a second named set HERE rather than letting a
138
+ # caller edit this one.
139
+ filter_project_paths() {
140
+ local _fp
141
+ while IFS= read -r _fp || [ -n "$_fp" ]; do
142
+ [ -n "$_fp" ] || continue
143
+ if ! jscpd_is_build_output "$_fp"; then
144
+ printf '%s\n' "$_fp"
145
+ fi
146
+ done
147
+ return 0
148
+ }
149
+
49
150
  # Echo the jscpd config the gate should pass, or nothing when the project has
50
151
  # none.
51
152
  #