@boyingliu01/xp-gate 0.32.0 → 0.32.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/adapters/powershell.sh +33 -7
  2. package/gate-3.sh +54 -5
  3. package/hooks/gate-3.sh +54 -5
  4. package/hooks/lib/jscpd-run.sh +92 -0
  5. package/hooks/pre-commit +111 -56
  6. package/lib/mattpocock-license-verdict.json +1 -1
  7. package/mock-policy/AGENTS.md +3 -3
  8. package/mutation/AGENTS.md +3 -3
  9. package/package.json +1 -1
  10. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  11. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  12. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  13. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  14. package/plugins/dsh/README.md +51 -2
  15. package/plugins/dsh/client/client.js +760 -0
  16. package/plugins/dsh/lib/client.js +760 -0
  17. package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
  18. package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
  19. package/plugins/dsh/lib/delphi-settings.js +76 -0
  20. package/plugins/dsh/lib/hooks.d.ts +14 -1
  21. package/plugins/dsh/lib/hooks.d.ts.map +1 -1
  22. package/plugins/dsh/lib/hooks.js +44 -5
  23. package/plugins/dsh/lib/index.d.ts +63 -0
  24. package/plugins/dsh/lib/index.d.ts.map +1 -1
  25. package/plugins/dsh/lib/index.js +126 -21
  26. package/plugins/dsh/package.json +13 -3
  27. package/plugins/dsh/scripts/copy-client.cjs +32 -0
  28. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/dsh/src/client.test.ts +231 -0
  32. package/plugins/dsh/src/delphi-settings.test.ts +101 -0
  33. package/plugins/dsh/src/delphi-settings.ts +111 -0
  34. package/plugins/dsh/src/hooks.test.ts +48 -0
  35. package/plugins/dsh/src/hooks.ts +45 -5
  36. package/plugins/dsh/src/index.test.ts +3 -2
  37. package/plugins/dsh/src/index.ts +147 -25
  38. package/plugins/opencode/package.json +1 -1
  39. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/qoder/plugin.json +1 -1
  43. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  44. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  45. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/principles/AGENTS.md +3 -3
  47. package/skills/delphi-review/AGENTS.md +3 -3
  48. package/skills/sprint-flow/AGENTS.md +3 -3
  49. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -268,6 +268,19 @@ run_static_analysis() {
268
268
  return 3
269
269
  fi
270
270
  fi
271
+ # Downstream hardening (ported): a scope file narrows the gate to product
272
+ # code, but it must be analysed under a DECLARED rule set. With no
273
+ # PSScriptAnalyzerSettings.psd1 the -Settings binding falls back to defaults,
274
+ # so a repo that declared its scope silently swapped rule sets. Refuse the
275
+ # run rather than grade against an unknown rule set.
276
+ #
277
+ # Mirrors the missing-module semantics (return 3 = fail closed upstream).
278
+ if [ "${#analysis_targets[@]}" -gt 0 ] && [ ! -f "PSScriptAnalyzerSettings.psd1" ]; then
279
+ echo "XP_G1_REASON: analysis-settings-missing:PSScriptAnalyzerSettings.psd1"
280
+ echo " ⚠️ $scope_file is in use but PSScriptAnalyzerSettings.psd1 is absent - refusing to analyse with an unknown rule set"
281
+ return 3
282
+ fi
283
+
271
284
 
272
285
  if [ "${#analysis_targets[@]}" -gt 0 ]; then
273
286
  # Scoped per-path run. -Path binds ONE string only (an array argument
@@ -430,14 +443,27 @@ run_lint() {
430
443
  # A hanging Pester test (e.g. a test that dot-sources a script whose body performs
431
444
  # WMI/registry/scheduled-task scans) would otherwise block `git commit` forever.
432
445
  # Default 300s, override via XP_GATE_POWERSHELL_TEST_TIMEOUT_S.
446
+ # #599: ONE helper, TWO budgets. The instrumented coverage run enables
447
+ # CodeCoverage, so instrumentation alone costs more than a plain run: a shared
448
+ # 300s budget is not the same headroom for both. The caller passes its default
449
+ # explicitly and the timeout message names the variable that actually governs it,
450
+ # otherwise a budget shortfall gets reported as "a test is hanging" and the
451
+ # reader is sent to the wrong place.
433
452
  _pester_timeout() {
434
- local default_s="${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}"
453
+ local budget_var="$1"
454
+ local default_s="${2:-300}"
455
+ local effective_s
456
+ if [ "$budget_var" = "XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S" ]; then
457
+ effective_s="${XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S:-$default_s}"
458
+ else
459
+ effective_s="${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-$default_s}"
460
+ fi
435
461
  # Prefer GNU timeout (Git Bash / Linux). On Windows where `timeout` resolves to
436
462
  # the native timeout.exe, fall back to running without a hard timeout.
437
463
  if command -v timeout >/dev/null 2>&1 && timeout --version 2>/dev/null | grep -qi "coreutils"; then
438
- timeout "${default_s}s" "$@"
464
+ timeout "${effective_s}s" "${@:3}"
439
465
  else
440
- "$@"
466
+ "${@:3}"
441
467
  fi
442
468
  }
443
469
 
@@ -465,7 +491,7 @@ run_tests() {
465
491
  if [ "${#test_files[@]}" -gt 0 ]; then
466
492
  test_paths=$(_powershell_path_array "${test_files[@]}")
467
493
  echo "Running Pester tests..."
468
- _pester_timeout "$PWSH" "${POWERSHELL_ARGS[@]}" "
494
+ _pester_timeout "XP_GATE_POWERSHELL_TEST_TIMEOUT_S" "${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}" "$PWSH" "${POWERSHELL_ARGS[@]}" "
469
495
  \$results = Invoke-Pester -Path $test_paths -PassThru
470
496
  if (\$results.FailedCount -gt 0) {
471
497
  Write-Host \"FAILED: \$(\$results.FailedCount) test(s)\"
@@ -476,7 +502,7 @@ run_tests() {
476
502
  "
477
503
  local run_exit=$?
478
504
  if [ "$run_exit" = "124" ]; then
479
- echo "⚠️ Pester tests TIMED OUT after ${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}s (may be hanging)."
505
+ echo "⚠️ Pester tests TIMED OUT after ${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}s (XP_GATE_POWERSHELL_TEST_TIMEOUT_S; may be hanging)."
480
506
  echo " A test may be blocking on a system call (WMI/registry/scheduled-task)."
481
507
  echo " Fix the hanging test, or set XP_GATE_POWERSHELL_TEST_TIMEOUT_S to a higher value."
482
508
  return 1
@@ -572,7 +598,7 @@ run_coverage() {
572
598
  # is the deprecated Pester 4 parameter set and fails outright on Pester 5.x
573
599
  # ("cannot resolve parameter set"). Using it silently produced no coverage.xml,
574
600
  # which the pre-commit hook then reported as an unparseable/zero result.
575
- _pester_timeout "$PWSH" "${POWERSHELL_ARGS[@]}" "
601
+ _pester_timeout "XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S" "${XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S:-900}" "$PWSH" "${POWERSHELL_ARGS[@]}" "
576
602
  \$cfg = New-PesterConfiguration
577
603
  \$cfg.Run.Path = $test_paths
578
604
  \$cfg.CodeCoverage.Enabled = \$true
@@ -587,7 +613,7 @@ run_coverage() {
587
613
  " 2>&1
588
614
  local cov_exit=$?
589
615
  if [ "$cov_exit" = "124" ]; then
590
- echo "⚠️ Pester coverage TIMED OUT after ${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}s (may be hanging)."
616
+ echo "⚠️ Pester coverage TIMED OUT after ${XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S:-900}s (XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S; instrumentation costs more than a plain run - raise this rather than suspecting a hang)."
591
617
  return 1
592
618
  fi
593
619
  return $cov_exit
package/gate-3.sh CHANGED
@@ -176,7 +176,42 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
176
176
  known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
177
177
  }
178
178
  }
179
- function norm(p) { return String(p).replace(/\\\\/g, "/").replace(/^\.\//, ""); }
179
+ // The measurement emits ABSOLUTE paths (pwsh resolved them), while
180
+ // a baseline recorded by a repo-side generator holds paths relative
181
+ // to the repo root. Normalising alone cannot bridge that, so a key
182
+ // miss falls back to a suffix match on the file path: exactly one
183
+ // known entry whose normalised path ends with "<sep>measured" is the
184
+ // same file. Without this every existing function looked brand new,
185
+ // and a real regression was reported as "new function" -- the right
186
+ // verdict for the wrong reason.
187
+ //
188
+ // Backslashes are normalised with split/join, NOT -replace: this JS
189
+ // is embedded in a double-quoted bash command, where a regex escape
190
+ // loses a level and silently matches the wrong number of
191
+ // backslashes. Measured: /\\\\/g arrived as /\\/g, matched only
192
+ // doubled separators, and every Windows path kept its backslashes.
193
+ function norm(p) {
194
+ return String(p)
195
+ .split("\\").join("/")
196
+ .replace(/^[A-Za-z]:\//, "")
197
+ .replace(/^\.\//, "")
198
+ .replace(/^\//, "");
199
+ }
200
+ function lookup(path, name) {
201
+ const key = norm(path) + "::" + name;
202
+ if (known.has(key)) return known.get(key);
203
+ const target = norm(path);
204
+ let hit = null;
205
+ for (const [k, v] of known) {
206
+ const [kp, kn] = k.split("::");
207
+ if (kn !== name) continue;
208
+ if (target.endsWith("/" + kp) || kp.endsWith("/" + target) || kp === target) {
209
+ if (hit !== null && hit !== v) return null; // ambiguous: treat as new
210
+ hit = v;
211
+ }
212
+ }
213
+ return hit;
214
+ }
180
215
  const seen = new Set();
181
216
  const regressions = [];
182
217
  const improved = [];
@@ -186,7 +221,7 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
186
221
  const key = norm(m[1]) + "::" + m[3];
187
222
  const now = Number(m[4]);
188
223
  seen.add(key);
189
- const was = known.has(key) ? known.get(key) : null;
224
+ const was = lookup(m[1], m[3]);
190
225
  if (was === null) {
191
226
  if (now > threshold) {
192
227
  regressions.push(m[1] + ":" + m[2] + " " + m[3] + " new function at ccn=" + now);
@@ -329,12 +364,26 @@ else
329
364
  # is reverted at the next publish, so grading it cannot be acted
330
365
  # on (doctorDelphi CCN=106 blocked every src/npm-package commit,
331
366
  # 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.
367
+ # test test dirs (tests/, test/, __tests__/), *.test.*, test_*.py,
368
+ # *_test.{py,go}, *Test(s).java — recorded in the report's test
369
+ # bucket, never in the baseline.
334
370
  # Both prefixes and the test pattern mirror ccn-report.cjs so the gate and
335
371
  # the writer cannot drift apart again (pinned by __tests__/gate-3-*.bats).
372
+ #
373
+ # #634: pytest's default is `python_files = test_*.py` under a `tests/`
374
+ # root. The previous pattern only knew the SUFFIX form `_test.{py,go}` and
375
+ # `*.test.*`, so a pytest-laid-out project had every test module graded as
376
+ # production source: test-helper complexity was reported as source debt and
377
+ # the ratchet could block on files the baseline deliberately never records
378
+ # (bucket === 'test').
379
+ # `(^|/)(tests?)/` — a tests/ or test/ directory anywhere in the tree
380
+ # `(^|/)test_[^/]*\.py$` — pytest default filenames at any depth
381
+ # Both are anchored with (^|/) so `latest/`, `contest/`, `attest/` and
382
+ # `my_test_dir/` are NOT caught (a leading `_?test_.*\.py$` would have
383
+ # swallowed `src/my_test_dir/bar.py`).
384
+ # Must stay equivalent to TEST_PATH in scripts/ccn-report.cjs.
336
385
  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)
386
+ '^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests?__/|(^|/)(tests?)/|\.test\.[cm]?[jt]sx?$|(^|/)test_[^/]*\.py$|_test\.(py|go)$|Test(s)?\.java$' || true)
338
387
 
339
388
  if [ -n "$CC_FILES" ]; then
340
389
  echo "Checking complexity for source files..."
package/hooks/gate-3.sh CHANGED
@@ -176,7 +176,42 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
176
176
  known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
177
177
  }
178
178
  }
179
- function norm(p) { return String(p).replace(/\\\\/g, "/").replace(/^\.\//, ""); }
179
+ // The measurement emits ABSOLUTE paths (pwsh resolved them), while
180
+ // a baseline recorded by a repo-side generator holds paths relative
181
+ // to the repo root. Normalising alone cannot bridge that, so a key
182
+ // miss falls back to a suffix match on the file path: exactly one
183
+ // known entry whose normalised path ends with "<sep>measured" is the
184
+ // same file. Without this every existing function looked brand new,
185
+ // and a real regression was reported as "new function" -- the right
186
+ // verdict for the wrong reason.
187
+ //
188
+ // Backslashes are normalised with split/join, NOT -replace: this JS
189
+ // is embedded in a double-quoted bash command, where a regex escape
190
+ // loses a level and silently matches the wrong number of
191
+ // backslashes. Measured: /\\\\/g arrived as /\\/g, matched only
192
+ // doubled separators, and every Windows path kept its backslashes.
193
+ function norm(p) {
194
+ return String(p)
195
+ .split("\\").join("/")
196
+ .replace(/^[A-Za-z]:\//, "")
197
+ .replace(/^\.\//, "")
198
+ .replace(/^\//, "");
199
+ }
200
+ function lookup(path, name) {
201
+ const key = norm(path) + "::" + name;
202
+ if (known.has(key)) return known.get(key);
203
+ const target = norm(path);
204
+ let hit = null;
205
+ for (const [k, v] of known) {
206
+ const [kp, kn] = k.split("::");
207
+ if (kn !== name) continue;
208
+ if (target.endsWith("/" + kp) || kp.endsWith("/" + target) || kp === target) {
209
+ if (hit !== null && hit !== v) return null; // ambiguous: treat as new
210
+ hit = v;
211
+ }
212
+ }
213
+ return hit;
214
+ }
180
215
  const seen = new Set();
181
216
  const regressions = [];
182
217
  const improved = [];
@@ -186,7 +221,7 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
186
221
  const key = norm(m[1]) + "::" + m[3];
187
222
  const now = Number(m[4]);
188
223
  seen.add(key);
189
- const was = known.has(key) ? known.get(key) : null;
224
+ const was = lookup(m[1], m[3]);
190
225
  if (was === null) {
191
226
  if (now > threshold) {
192
227
  regressions.push(m[1] + ":" + m[2] + " " + m[3] + " new function at ccn=" + now);
@@ -329,12 +364,26 @@ else
329
364
  # is reverted at the next publish, so grading it cannot be acted
330
365
  # on (doctorDelphi CCN=106 blocked every src/npm-package commit,
331
366
  # 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.
367
+ # test test dirs (tests/, test/, __tests__/), *.test.*, test_*.py,
368
+ # *_test.{py,go}, *Test(s).java — recorded in the report's test
369
+ # bucket, never in the baseline.
334
370
  # Both prefixes and the test pattern mirror ccn-report.cjs so the gate and
335
371
  # the writer cannot drift apart again (pinned by __tests__/gate-3-*.bats).
372
+ #
373
+ # #634: pytest's default is `python_files = test_*.py` under a `tests/`
374
+ # root. The previous pattern only knew the SUFFIX form `_test.{py,go}` and
375
+ # `*.test.*`, so a pytest-laid-out project had every test module graded as
376
+ # production source: test-helper complexity was reported as source debt and
377
+ # the ratchet could block on files the baseline deliberately never records
378
+ # (bucket === 'test').
379
+ # `(^|/)(tests?)/` — a tests/ or test/ directory anywhere in the tree
380
+ # `(^|/)test_[^/]*\.py$` — pytest default filenames at any depth
381
+ # Both are anchored with (^|/) so `latest/`, `contest/`, `attest/` and
382
+ # `my_test_dir/` are NOT caught (a leading `_?test_.*\.py$` would have
383
+ # swallowed `src/my_test_dir/bar.py`).
384
+ # Must stay equivalent to TEST_PATH in scripts/ccn-report.cjs.
336
385
  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)
386
+ '^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests?__/|(^|/)(tests?)/|\.test\.[cm]?[jt]sx?$|(^|/)test_[^/]*\.py$|_test\.(py|go)$|Test(s)?\.java$' || true)
338
387
 
339
388
  if [ -n "$CC_FILES" ]; then
340
389
  echo "Checking complexity for source files..."
@@ -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".
@@ -55,6 +104,49 @@ jscpd_is_default_ignored() {
55
104
  printf '%s\n' "$_path" | grep -qE "$JSCPD_DEFAULT_IGNORE_PATTERNS"
56
105
  }
57
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
+
58
150
  # Echo the jscpd config the gate should pass, or nothing when the project has
59
151
  # none.
60
152
  #
package/hooks/pre-commit CHANGED
@@ -710,48 +710,37 @@ if [ "$is_protected" = "true" ]; then
710
710
  esac
711
711
  done
712
712
 
713
- # Doc-only changes: no source code files
713
+ # Doc-only / build-tooling-only changes need no VERSION bump.
714
+ #
715
+ # #624 defect 2: the split used to test ONLY `has_source`, which is true for
716
+ # any .py/.ts/... path INCLUDING `scripts/*`. So a commit that touched
717
+ # nothing but build tooling still fell into the version-check branch and was
718
+ # blocked. `has_real_source` is exactly "production source that needs a bump"
719
+ # — it was computed a few lines up but never consulted here. It must decide.
714
720
  if [ "$has_source" = "false" ]; then
715
721
  echo "✅ PASSED - Gate 0: Version Consistency Check (doc-only changes)"
722
+ elif [ "$has_real_source" = "false" ]; then
723
+ echo "✅ PASSED - Gate 0: Version Consistency Check (build-tooling-only changes)"
716
724
  else
717
- # Check for bypass: commit message prefix (read from COMMIT_EDITMSG during pre-commit hook)
718
- COMMIT_MSG=$(cat "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null | head -1 || echo "")
719
- if echo "$COMMIT_MSG" | grep -qE '^\[skip-version-check\]'; then
720
- if echo "$COMMIT_MSG" | grep -qE '^\[skip-version-check\].*(chore:|docs:|release:)'; then
721
- if [ "$has_real_source" = "false" ]; then
722
- echo "✅ PASSED - Gate 0: Version Consistency Check (bypass: chore/docs/release)"
723
- else
724
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
725
- VERSION CONSISTENCY CHECK (Gate 0)
726
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
727
- Branch: $CURRENT_BRANCH
728
- ERROR: [skip-version-check] bypass used but production source files detected.
729
-
730
- Bypass only allows build-tooling changes (adapters/, scripts/, hooks/).
731
- Production source files require VERSION/CHANGELOG update.
732
-
733
- Files: $(echo "$STAGED_FILES" | tr '\n' ', ' | sed 's/,$//')
734
- "
735
- exit 1
736
- fi
737
- else
738
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
739
- VERSION CONSISTENCY CHECK (Gate 0)
740
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
741
- Branch: $CURRENT_BRANCH
742
- ERROR: [skip-version-check] bypass prefix invalid.
743
-
744
- Allowed prefixes: chore:, docs:, release:
745
- Example: [skip-version-check] chore: update dependencies
746
- "
747
- exit 1
748
- fi
749
- else
750
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
725
+ # Production source on a protected branch without VERSION/CHANGELOG.
726
+ #
727
+ # #624 defect 1: this branch used to offer a `[skip-version-check]` message
728
+ # prefix as a bypass, read from `.git/COMMIT_EDITMSG`. That cannot work:
729
+ # git runs pre-commit BEFORE it builds the message, so the file does not
730
+ # exist yet (`-m`, `-F` and the interactive editor path all measured on
731
+ # git 2.54), and the hook gets no arguments. What the old code read was the
732
+ # PREVIOUS commit's message, so the prefix never matched — the documented
733
+ # recipe silently could not clear the gate, and --no-verify was the only
734
+ # way through. Rather than keep a bypass whose input this hook structurally
735
+ # cannot see, the escape hatch is the one that actually works and is
736
+ # already documented in CHANGELOG: the SKIP_VERSION_CHECK=1 environment
737
+ # variable (handled earlier in this Gate 0 block).
738
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
751
739
  VERSION CONSISTENCY CHECK (Gate 0)
752
740
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
753
741
  Branch: $CURRENT_BRANCH
754
- ERROR: Committing to a protected branch without VERSION/CHANGELOG update.
742
+ ERROR: Committing production source to a protected branch without a
743
+ VERSION/CHANGELOG update.
755
744
 
756
745
  This bypasses the ship workflow and causes version drift.
757
746
  Before committing:
@@ -759,18 +748,19 @@ Before committing:
759
748
  2. Add entry to CHANGELOG.md
760
749
  3. Run: node scripts/sync-version.cjs
761
750
 
762
- Or use the bypass prefix — the commit message FIRST LINE must START with:
763
- [skip-version-check] chore: <description>
764
- Allowed types: chore: docs: release:. The bypass only permits build
765
- tooling changes (adapters/, scripts/, hooks/); production source files
766
- still require a VERSION/CHANGELOG update.
751
+ Build-tooling-only changes (adapters/, scripts/, hooks/) need NO version bump
752
+ and pass this gate automatically — no marker required.
753
+
754
+ If you genuinely must land production source without a version bump, the
755
+ supported escape hatch is the env var (a message prefix cannot work here
756
+ because pre-commit runs before git builds the message):
757
+ SKIP_VERSION_CHECK=1 git commit ...
767
758
 
768
759
  Staged files: $(echo "$STAGED_FILES" | tr '\n' ', ' | sed 's/,$//')
769
760
  "
770
- GATE_0_STATUS="BLOCK"
771
- record_gate_audit "gate-0" "version-consistency" "$GATE_0_STATUS" "1" "$GATE_0_START"
772
- exit 1
773
- fi
761
+ GATE_0_STATUS="BLOCK"
762
+ record_gate_audit "gate-0" "version-consistency" "$GATE_0_STATUS" "1" "$GATE_0_START"
763
+ exit 1
774
764
  fi
775
765
  fi
776
766
  fi # closes SKIP_VERSION_CHECK if/else
@@ -809,7 +799,44 @@ echo ""
809
799
  MIRROR_PATH_FILTER='^src/npm-package/'
810
800
  DASHBOARD_PATH_FILTER='^dashboard/'
811
801
  RAW_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
812
- CHANGED_FILES=$(printf '%s\n' "$RAW_CHANGED_FILES" | grep -v "$MIRROR_PATH_FILTER" | grep -v "$DASHBOARD_PATH_FILTER" || true)
802
+
803
+ # Dependencies and build output are not project code (#649).
804
+ #
805
+ # `git diff --cached` knows nothing about .gitignore: anything that reaches the
806
+ # index is listed. A project that vendors its dependencies, a CI fixture that
807
+ # commits after `npm install`, an accidental `git add -A`, or a monorepo's
808
+ # nested `packages/*/node_modules` therefore hands every downstream scanner a
809
+ # list containing third-party source, and each scanner judges it as the
810
+ # project's own.
811
+ #
812
+ # This has already cost two outages, one language branch at a time: #595 (Gate 1
813
+ # PowerShell arm blocked every commit once Playwright's node_modules was
814
+ # scanned for Write-Host) and #648 (Gate 1 JS/TS arm blocked on the bare
815
+ # `debugger;` in node_modules/typescript/dist/internal/utils.js). Both were
816
+ # fixed at the single site that happened to fail, so the next branch re-broke
817
+ # the same way. The filter belongs HERE, at the one place the list is built, so
818
+ # a scanner added tomorrow is safe by default instead of by memory.
819
+ #
820
+ # The exclusion itself is the shared build-output list from jscpd-run.sh
821
+ # (jscpd_is_build_output), so the directory names have one definition across the
822
+ # gates. Deliberately NOT the full Gate 2 ignore set: that one also drops
823
+ # *.yaml/*.yml/*.txt/*.log because repetition there is data shape rather than a
824
+ # refactoring target, and using it here would make a commit that only adds
825
+ # infra/deployment.yaml look like "No code files changed" -- skipping the IaC
826
+ # and lint gates that exist to scan exactly that file. The XP_GATE_JSCPD_DEFAULT_IGNORE=0
827
+ # escape hatch still applies.
828
+ #
829
+ # RAW_CHANGED_FILES stays unfiltered on purpose: it is the only truthful answer
830
+ # to "did the user stage anything at all", and a future "you accidentally
831
+ # committed dependencies" warning needs to see what this filter removed.
832
+ CHANGED_FILES=$(printf '%s\n' "$RAW_CHANGED_FILES" \
833
+ | grep -v "$MIRROR_PATH_FILTER" | grep -v "$DASHBOARD_PATH_FILTER" \
834
+ | {
835
+ while IFS= read -r _cf; do
836
+ [ -n "$_cf" ] || continue
837
+ jscpd_is_build_output "$_cf" || printf '%s\n' "$_cf"
838
+ done
839
+ } || true)
813
840
 
814
841
  # REQ-F2: one commit-level docs-only verdict, computed once and reused by the
815
842
  # heavy gates (2/5/9/12). Light gates never consult it — a secret in a README
@@ -844,7 +871,22 @@ any_changed_files_match() {
844
871
  local pattern
845
872
 
846
873
  for pattern in "$@"; do
847
- if printf '%s\n' "$CHANGED_FILES" | grep -qE -- "$pattern"; then
874
+ # Here-string, not `printf | grep -q`. Under the `set -o pipefail` above,
875
+ # `grep -q` exits the moment it matches, the writer takes SIGPIPE, and the
876
+ # pipeline reports 141 -- so the `if` reads "no match" even though grep DID
877
+ # match. Whether that happens depends on whether the list fits in the pipe
878
+ # buffer, which is why it showed up as a size-dependent, flaky SKIP.
879
+ #
880
+ # It was not theoretical: in the Cross-Platform CI first-commit fixture the
881
+ # staged list is long enough to trip it, and Gate 1 announced "No code files
882
+ # changed, skipping lint" for a commit that had staged 19 shell scripts --
883
+ # while Gate 2, reading the same variable, happily scanned them and reported
884
+ # duplicates. The hook log carried the fingerprint the whole time:
885
+ # `line 847: printf: Broken pipe`.
886
+ #
887
+ # A here-string has no writer process to be killed, so the match cannot be
888
+ # lost. Same reason `run_tsx` and the coverage probes avoid `| head`.
889
+ if grep -qE -- "$pattern" <<< "$CHANGED_FILES"; then
848
890
  return 0
849
891
  fi
850
892
  done
@@ -1524,7 +1566,7 @@ else
1524
1566
  TS_DEBUG_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx)$' | {
1525
1567
  while IFS= read -r _df; do
1526
1568
  [ -n "$_df" ] || continue
1527
- if ! jscpd_is_default_ignored "$_df"; then
1569
+ if ! jscpd_is_build_output "$_df"; then
1528
1570
  printf '%s\n' "$_df"
1529
1571
  fi
1530
1572
  done
@@ -2212,7 +2254,10 @@ else
2212
2254
  fi
2213
2255
 
2214
2256
  # --- BLOCK path: New .ts/.tsx files without corresponding tests ---
2215
- NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -v 'node_modules/' | grep -v '\.next/' | grep -v '\.nuxt/' | grep -v 'dist/' | grep -v 'build/' | grep -v '.turbo/' | grep -v '.cache/' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
2257
+ # Exclusions come from filter_project_paths (#649). The hand-maintained list
2258
+ # this replaced omitted vendor/target/coverage/out, so a staged vendor/x.ts
2259
+ # was reported as new source with no test.
2260
+ NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
2216
2261
 
2217
2262
  # Strip PROJECT_SUBDIR prefix from new-TS-file paths ONLY when the hook
2218
2263
  # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1; single-language
@@ -2297,10 +2342,12 @@ else
2297
2342
  fi
2298
2343
 
2299
2344
  # --- WARNING path: Modified files (any lang) + New non-TS files ---
2345
+ # filter_project_paths strips dependencies and build output (#649); without
2346
+ # it a staged vendor/ or target/ module is reported as source needing a test.
2300
2347
  WARNING_SOURCE_FILES=$(
2301
2348
  {
2302
- git diff --cached --name-only --diff-filter=M | grep -E '\.(ts|tsx|py|go|java|kt|kts|cpp|cc|cxx|c|swift|dart|rb|rs)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__'
2303
- git diff --cached --name-only --diff-filter=A | grep -E '\.(py|go|java|kt|kts|cpp|cc|cxx|c|swift|dart|rb|rs)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__'
2349
+ git diff --cached --name-only --diff-filter=M | filter_project_paths | grep -E '\.(ts|tsx|py|go|java|kt|kts|cpp|cc|cxx|c|swift|dart|rb|rs)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__'
2350
+ git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -E '\.(py|go|java|kt|kts|cpp|cc|cxx|c|swift|dart|rb|rs)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__'
2304
2351
  } | sort -u || true
2305
2352
  )
2306
2353
 
@@ -2423,7 +2470,7 @@ else
2423
2470
  # Gate 5b: Mock Density ADVISORY Scan (pure bash — no npx tsx overhead)
2424
2471
  # 30% = ADVISORY, 50% = suggests @mock-justified. Does NOT block commit.
2425
2472
  # ========================================================================
2426
- CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
2473
+ CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | filter_project_paths | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
2427
2474
 
2428
2475
  # Nested plugin sub-projects (plugins/<name>/) own their own vitest config and
2429
2476
  # are excluded from the root suite (`vitest.config.ts` excludes `plugins/**`,
@@ -2571,7 +2618,7 @@ else
2571
2618
  CHANGED_TEST_FILE_COUNT=$(echo "$SELECTED_TEST_FILES" | grep -c '.' || true)
2572
2619
 
2573
2620
  # Also get changed source files (non-test) for related-test lookup
2574
- CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
2621
+ CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM | filter_project_paths | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
2575
2622
  # Nested plugin sub-projects are excluded from the root suite; their
2576
2623
  # sources must not drive root-vitest related-test lookup either (it
2577
2624
  # would resolve to plugin-local paths vitest cannot collect).
@@ -2807,9 +2854,14 @@ else
2807
2854
  # full -> unchanged behavior: whole suite + real threshold.
2808
2855
  # skip -> nothing test-relevant changed; no pytest at all.
2809
2856
  PY_CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2857
+ | filter_project_paths \
2810
2858
  | grep -E '(^|/)test_[^/]*\.py$|_test\.py$' || true)
2811
2859
  PY_CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2860
+ | filter_project_paths \
2812
2861
  | grep -E '\.py$' | grep -vE '(^|/)test_[^/]*\.py$|_test\.py$' || true)
2862
+ # NOT filtered on purpose: this list answers "did a requirements
2863
+ # file change", which is a property of the whole commit including
2864
+ # paths under a vendor/ tree that filter_project_paths removes.
2813
2865
  PY_CHANGED_ALL=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)
2814
2866
  # `grep -c '.'` prints its own zero AND exits 1 on an empty list
2815
2867
  # (#493), so the count is derived with `|| true` + wc -l instead.
@@ -3363,7 +3415,7 @@ else
3363
3415
  # New files must have ≥80% coverage individually.
3364
3416
  # ============================================================================
3365
3417
  if has_project_lang "typescript" && [ -f "coverage/coverage-summary.json" ]; then
3366
- NEW_SOURCE_FILES=$(git diff --cached --name-only --diff-filter=A | grep -E '\.(ts|tsx)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -v '\.d\.ts$' || true)
3418
+ NEW_SOURCE_FILES=$(git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -E '\.(ts|tsx)$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -v '\.d\.ts$' || true)
3367
3419
  if [ -n "$NEW_SOURCE_FILES" ]; then
3368
3420
  echo ""
3369
3421
  echo " └─ Per-file coverage check for new files:"
@@ -3764,8 +3816,11 @@ else
3764
3816
  # governed by Mirror Parity in CI, which compares content rather than
3765
3817
  # re-linting it. (See the same exclusion in adapters/powershell.sh.)
3766
3818
  MIRROR_EXCLUDE="$MIRROR_PATH_FILTER"
3767
- NEW_FILES=$(git diff --cached --name-only --diff-filter=A | grep -v "$MIRROR_EXCLUDE" | grep -v "$DASHBOARD_PATH_FILTER" | tr '\n' ' ')
3768
- MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | grep -v "$MIRROR_EXCLUDE" | grep -v "$DASHBOARD_PATH_FILTER" | tr '\n' ' ')
3819
+ # filter_project_paths also removes dependencies and build output (#649):
3820
+ # a staged node_modules/ or dist/ file is not budgetable project code, and
3821
+ # entering it in the Boy Scout baseline would budget a path nobody edits.
3822
+ NEW_FILES=$(git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -v "$MIRROR_EXCLUDE" | grep -v "$DASHBOARD_PATH_FILTER" | tr '\n' ' ')
3823
+ MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | filter_project_paths | grep -v "$MIRROR_EXCLUDE" | grep -v "$DASHBOARD_PATH_FILTER" | tr '\n' ' ')
3769
3824
 
3770
3825
  if [ -z "$NEW_FILES" ] && [ -z "$MODIFIED_FILES" ]; then
3771
3826
  echo " ✅ No new or modified files to check."
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-28T17:43:58.740Z",
5
+ "checked_at": "2026-10-30T08:22:09.989Z",
6
6
  "source": "network"
7
7
  }