@boyingliu01/xp-gate 0.31.1 → 0.31.3

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 (86) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +37 -1
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +254 -10
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +254 -10
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
  14. package/hooks/lib/coverage-total.sh +20 -5
  15. package/hooks/lib/gate-5c-alignment.sh +145 -0
  16. package/hooks/lib/jscpd-run.sh +40 -1
  17. package/hooks/lib/native-path.sh +106 -0
  18. package/hooks/lib/prepush-score.py +143 -0
  19. package/hooks/lib/sprint-gate-report.sh +23 -0
  20. package/hooks/lib/toolchain-path.sh +370 -0
  21. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  22. package/hooks/pre-commit +403 -165
  23. package/hooks/pre-push +192 -74
  24. package/hooks/sprint-gate.sh +14 -3
  25. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  26. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  27. package/lib/__tests__/doctor-delphi.test.js +46 -0
  28. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  29. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  30. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  31. package/lib/__tests__/doctor.test.js +7 -1
  32. package/lib/__tests__/hook-health-path.test.js +9 -1
  33. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  34. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  35. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  36. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  37. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  38. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  39. package/lib/arch.js +261 -44
  40. package/lib/check-alignment-runner.cjs +23 -0
  41. package/lib/check.js +4 -1
  42. package/lib/doctor.js +194 -49
  43. package/lib/gate-runner.js +265 -58
  44. package/lib/mattpocock-license-verdict.json +1 -1
  45. package/lib/principles.js +228 -6
  46. package/lib/shared-paths.js +53 -19
  47. package/mock-policy/AGENTS.md +3 -3
  48. package/mutation/AGENTS.md +3 -3
  49. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  50. package/package.json +1 -1
  51. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  52. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  53. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  54. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  55. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  56. package/plugins/dsh/package.json +1 -1
  57. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  59. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  60. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  61. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  62. package/plugins/dsh/src/delphi-config.ts +151 -9
  63. package/plugins/opencode/package.json +1 -1
  64. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  65. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  70. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  71. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  73. package/principles/AGENTS.md +3 -3
  74. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  75. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  76. package/principles/adapters/java.ts +127 -11
  77. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  78. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  79. package/principles/rules/solid/lsp.ts +348 -23
  80. package/scripts/__tests__/sync-package-content.test.js +32 -1
  81. package/scripts/sync-package-content.js +6 -1
  82. package/skills/delphi-review/AGENTS.md +3 -3
  83. package/skills/delphi-review/SKILL.md +50 -0
  84. package/skills/sprint-flow/AGENTS.md +3 -3
  85. package/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/sprint-gate.sh +14 -3
@@ -0,0 +1,186 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Measure cyclomatic complexity of PowerShell functions via the AST.
4
+
5
+ .DESCRIPTION
6
+ lizard cannot parse PowerShell, so a PowerShell-primary repository had a
7
+ permanently unmeasured complexity dimension (#537). The platform, however,
8
+ CAN answer the question: PowerShell's own AST yields an exact decision-point
9
+ count per function, which is the same quantity lizard measures for other
10
+ languages.
11
+
12
+ This script is the measurement half; gate-3.sh is the verdict half. Keeping
13
+ them apart means the gate does not have to know a project's refactor history
14
+ and the script does not have to know about baselines.
15
+
16
+ Output is line-oriented and stable, because gate-3.sh parses it:
17
+
18
+ functions_measured=<n> total functions inspected
19
+ max_ccn=<n> highest CCN seen
20
+ over_threshold=<n> functions with CCN > -Threshold
21
+ OVER <file>:<line> <name> ccn=<n> one per over-threshold function
22
+ FUNC <file>:<line> <name> ccn=<n> one per function when -List is set
23
+
24
+ .PARAMETER Path
25
+ Files or directories to analyse. Directories are walked for *.ps1/*.psm1.
26
+
27
+ .PARAMETER Threshold
28
+ Reporting threshold. Functions above it are emitted as OVER lines. This does
29
+ NOT filter FUNC lines, which is what makes a ratchet able to compare against
30
+ a baseline collected at a different threshold -- see #598.
31
+
32
+ .PARAMETER List
33
+ Emit a FUNC line for every function, not just the ones over the threshold.
34
+ A ratchet needs the full population: comparing only OVER lines against a
35
+ baseline that also stores only over-threshold entries is how a brand-new
36
+ well-factored function (CCN 2-4) got reported as a "regression".
37
+
38
+ .PARAMETER Strict
39
+ Treat a parse failure as a hard error (exit 3) instead of skipping the file.
40
+
41
+ .EXAMPLE
42
+ ./Measure-PowerShellComplexity.ps1 -Path ./src -Threshold 5
43
+
44
+ .EXAMPLE
45
+ ./Measure-PowerShellComplexity.ps1 -Path ./src -Threshold 5 -List
46
+
47
+ .NOTES
48
+ Exit codes:
49
+ 0 measured (findings are reported, not signalled by exit code)
50
+ 2 no analyzable files
51
+ 3 parse/tooling failure (fail closed)
52
+ #>
53
+ [CmdletBinding()]
54
+ param(
55
+ [Parameter(Mandatory = $false)]
56
+ [string[]] $Path = @('.'),
57
+
58
+ [Parameter(Mandatory = $false)]
59
+ [ValidateRange(1, 1000)]
60
+ [int] $Threshold = 5,
61
+
62
+ [Parameter(Mandatory = $false)]
63
+ [switch] $List,
64
+
65
+ [Parameter(Mandatory = $false)]
66
+ [switch] $Strict
67
+ )
68
+
69
+ Set-StrictMode -Version Latest
70
+ $ErrorActionPreference = 'Stop'
71
+
72
+ # --- Collect files -----------------------------------------------------------
73
+
74
+ $files = New-Object System.Collections.Generic.List[string]
75
+ $missing = 0
76
+
77
+ foreach ($p in $Path) {
78
+ if (-not (Test-Path -LiteralPath $p)) { $missing++; continue }
79
+ $item = Get-Item -LiteralPath $p
80
+ if ($item.PSIsContainer) {
81
+ Get-ChildItem -LiteralPath $item.FullName -Recurse -File -Include '*.ps1', '*.psm1' |
82
+ ForEach-Object { $files.Add($_.FullName) }
83
+ }
84
+ elseif ($item.Extension -in @('.ps1', '.psm1')) {
85
+ $files.Add($item.FullName)
86
+ }
87
+ }
88
+
89
+ if ($files.Count -eq 0) {
90
+ if ($Strict -and $missing -gt 0) {
91
+ [Console]::Error.WriteLine("ERR no analyzable PowerShell files ($missing path(s) did not exist)")
92
+ exit 3
93
+ }
94
+ Write-Output 'functions_measured=0'
95
+ Write-Output 'max_ccn=0'
96
+ Write-Output 'over_threshold=0'
97
+ exit 2
98
+ }
99
+
100
+ # --- Count decision points ---------------------------------------------------
101
+ #
102
+ # CCN = 1 + number of decision points, which is the definition lizard uses.
103
+ # Decision points in PowerShell: if / elseif, the loop keywords, catch, the
104
+ # ternary and null-coalescing operators, and each -and/-or in a condition.
105
+ # Declaring a function is not itself a decision, so every function starts at 1.
106
+
107
+ function Get-DecisionCount {
108
+ param([System.Management.Automation.Language.Ast] $AstBody)
109
+
110
+ $count = 0
111
+
112
+ $count += @($AstBody.FindAll({ param($n) $n -is [System.Management.Automation.Language.IfStatementAst] }, $true)).Count
113
+ $count += @($AstBody.FindAll({ param($n) $n -is [System.Management.Automation.Language.LoopStatementAst] }, $true)).Count
114
+ $count += @($AstBody.FindAll({ param($n) $n -is [System.Management.Automation.Language.TrapStatementAst] }, $true)).Count
115
+ $count += @($AstBody.FindAll({ param($n) $n -is [System.Management.Automation.Language.TernaryExpressionAst] }, $true)).Count
116
+
117
+ # Binary expressions whose operator short-circuits count as decisions, which
118
+ # is what makes `if ($a -and $b -or $c)` score higher than `if ($a)`.
119
+ foreach ($bin in $AstBody.FindAll({ param($n) $n -is [System.Management.Automation.Language.BinaryExpressionAst] }, $true)) {
120
+ $op = $null
121
+ try { $op = $bin.Operator } catch { continue }
122
+ if ($op -eq 'And' -or $op -eq 'Or') { $count++ }
123
+ }
124
+
125
+ # catch blocks: FindAll over class-based ASTs is not available for them, so
126
+ # walk TryStatementAst nodes and count their CatchClauses.
127
+ foreach ($try in $AstBody.FindAll({ param($n) $n -is [System.Management.Automation.Language.TryStatementAst] }, $true)) {
128
+ $count += @($try.CatchClauses).Count
129
+ }
130
+
131
+ return $count
132
+ }
133
+
134
+ $measured = 0
135
+ $maxCcn = 0
136
+ $over = 0
137
+ $parseErrors = New-Object System.Collections.Generic.List[string]
138
+
139
+ foreach ($file in $files) {
140
+ $tokens = $null
141
+ $errors = $null
142
+ $ast = $null
143
+ try {
144
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors)
145
+ }
146
+ catch {
147
+ $parseErrors.Add("$file : $($_.Exception.Message)")
148
+ continue
149
+ }
150
+
151
+ if ($errors -and $errors.Count -gt 0) {
152
+ $first = ($errors | Select-Object -First 1)
153
+ $parseErrors.Add("$file : $($first.Message)")
154
+ continue
155
+ }
156
+
157
+ $funcs = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
158
+ foreach ($f in $funcs) {
159
+ $ccn = 1 + (Get-DecisionCount -AstBody $f.Body)
160
+ $measured++
161
+ if ($ccn -gt $maxCcn) { $maxCcn = $ccn }
162
+
163
+ if ($ccn -gt $Threshold) { $over++ }
164
+
165
+ if ($List -or $ccn -gt $Threshold) {
166
+ $line = $f.Extent.StartLineNumber
167
+ $kind = if ($ccn -gt $Threshold) { 'OVER' } elseif ($List) { 'FUNC' } else { $null }
168
+ if ($kind) {
169
+ Write-Output ('{0} {1}:{2} {3} ccn={4}' -f $kind, $file, $line, $f.Name, $ccn)
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ if ($parseErrors.Count -gt 0 -and $Strict) {
176
+ foreach ($pe in $parseErrors) { [Console]::Error.WriteLine("ERR parse: $pe") }
177
+ exit 3
178
+ }
179
+ elseif ($parseErrors.Count -gt 0) {
180
+ foreach ($pe in $parseErrors) { [Console]::Error.WriteLine("WARN parse skipped: $pe") }
181
+ }
182
+
183
+ Write-Output "functions_measured=$measured"
184
+ Write-Output "max_ccn=$maxCcn"
185
+ Write-Output "over_threshold=$over"
186
+ exit 0
@@ -81,12 +81,27 @@ enforce_python_coverage() {
81
81
  echo " Run the full suite for a real Python coverage check."
82
82
  return 0
83
83
  fi
84
- echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
84
+ echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD:-80}% threshold"
85
85
  return 1
86
86
  fi
87
- echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
88
- else
89
- echo "⚠️ Could not determine Python coverage percentage. Ensure pytest-cov or coverage.py data exists."
87
+ echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ ${COVERAGE_THRESHOLD:-80}%)"
88
+ return 0
89
+ fi
90
+
91
+ # #570: reaching here means the gate produced NO measurement. That is a FAILED
92
+ # answer to "is coverage at or above the floor?", not a lenient one — the gate
93
+ # exists to answer that question and answered nothing. Warning and returning 0
94
+ # made the gate fail-open: deleting .coverage (or a pytest run that died before
95
+ # writing it) disabled the coverage check while the summary still looked clean.
96
+ #
97
+ # The one legitimate exception is a subset run: nothing was measured because
98
+ # only part of the suite was run, so there is no total to compare.
99
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
100
+ echo "⚠️ Could not determine Python coverage percentage (subset test run) — not blocking."
101
+ return 0
90
102
  fi
91
- return 0
103
+ echo "❌ BLOCKED - Could not determine Python coverage percentage."
104
+ echo " No .coverage / coverage report was produced, so the ${COVERAGE_THRESHOLD:-80}% floor"
105
+ echo " cannot be verified. Run the suite with coverage (pytest --cov) and commit again."
106
+ return 1
92
107
  }
@@ -0,0 +1,145 @@
1
+ #!/bin/bash
2
+ # Gate 5c: spec/test alignment (#585).
3
+ #
4
+ # Extracted from pre-commit so the contract is unit-testable without executing the
5
+ # hook, the same way lib/coverage-total.sh and lib/sprint-gate-report.sh are.
6
+ #
7
+ # The defect this file exists for: the whole check sat inside
8
+ # if [ -f "$REQ_IDS_FILE" ] && command -v jq >/dev/null 2>&1; then
9
+ # so in the normal state — req-ids.json is produced by `xp-gate check-alignment`
10
+ # into .sprint-state/, which is gitignored and therefore absent in every fresh
11
+ # clone — the gate printed NOTHING. Not PASS, not FAIL, not SKIP. The quality
12
+ # report had no slot for it either. A repository's semantic alignment had never
13
+ # been checked once and every report was green.
14
+ #
15
+ # A gate that answers "not checked" by staying silent is the same defect class as
16
+ # #449, #499, #570, #575 and #594: silence read as success.
17
+
18
+ # Test-file conventions, per language. A gate that only knows JS/TS reports
19
+ # "0 annotated, 0 uncovered" on a PowerShell, Python, Go, Java or Kotlin
20
+ # repository — a vacuous green (#585).
21
+ #
22
+ # Pester: *.Tests.ps1, *.test.ps1 (Pester's documented naming)
23
+ # pytest: test_*.py, *_test.py
24
+ # Go: *_test.go
25
+ # Java: *Test.java, *Tests.java
26
+ # Kotlin: *Test.kt, *Tests.kt
27
+ # JS/TS: *.test.*, *.spec.*
28
+ GATE5C_TEST_FILE_PATTERN='(\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs)$)|(\.([Tt]ests?)\.ps1$)|(^(test_|.*/test_)[^/]*\.py$)|(^([^/]*_test)\.py$)|(_test\.go$)|([Tt]ests?\.java$)|([Tt]ests?\.kt$)'
29
+
30
+ # Is this changed-file list carrying anything that looks like a test file?
31
+ #
32
+ # Uses the same CHANGED_FILES variable the hook holds at that point in its flow.
33
+ gate_5c_test_files() {
34
+ printf '%s\n' "${CHANGED_FILES:-}" \
35
+ | grep -E "$GATE5C_TEST_FILE_PATTERN" 2>/dev/null || true
36
+ }
37
+
38
+ # One-line-per-id extraction. Reads req-ids.json when jq is available and falls
39
+ # back to grep when it is not: `jq` missing used to disable the gate silently, and
40
+ # a JSON array of strings is simple enough to read without it.
41
+ gate_5c_req_ids() {
42
+ local file="$1"
43
+ if [ ! -f "$file" ]; then
44
+ return 0
45
+ fi
46
+ if command -v jq >/dev/null 2>&1; then
47
+ jq -r '.[]' "$file" 2>/dev/null && return 0
48
+ fi
49
+ # Fallback: quoted strings inside the array. Good enough for a flat id list, and
50
+ # it fails toward EMPTY rather than toward a bogus id.
51
+ grep -oE '"[^"]+"' "$file" 2>/dev/null | tr -d '"' || true
52
+ }
53
+
54
+ # The @test REQ-XXX annotations inside one file.
55
+ gate_5c_annotations() {
56
+ local file="$1"
57
+ grep -oE '@test[[:space:]]+REQ-[A-Za-z0-9-]+' "$file" 2>/dev/null \
58
+ | sed 's/@test[[:space:]]*//' | sort -u || true
59
+ }
60
+
61
+ # Gate 5c entry point. Echoes the verdict; returns 1 to BLOCK.
62
+ #
63
+ # Reads CHANGED_FILES (set by pre-commit). Writes GATE_5C_STATUS for the report:
64
+ # PASS, SKIP or BLOCK — never empty, because "empty" is what was broken.
65
+ gate_5c_alignment() {
66
+ local req_ids_file=".sprint-state/phase-outputs/req-ids.json"
67
+ GATE_5C_STATUS=""
68
+
69
+ if [ ! -f "$req_ids_file" ]; then
70
+ # The normal state of a fresh clone: .sprint-state/ is a process artifact and
71
+ # gitignored. Say so, out loud, with the command that would fix it.
72
+ echo " ⏭️ Gate 5c: spec/test alignment NOT CHECKED — $req_ids_file missing"
73
+ echo " Run 'xp-gate check-alignment' to generate it and enable this gate."
74
+ GATE_5C_STATUS="SKIP"
75
+ return 0
76
+ fi
77
+
78
+ # Staleness: a req-ids.json older than the spec it claims to describe would let a
79
+ # deleted REQ keep its annotations looking valid.
80
+ if [ -f "specification.yaml" ] && [ "$req_ids_file" -ot "specification.yaml" ]; then
81
+ echo " ⏭️ Gate 5c: spec/test alignment NOT CHECKED — req-ids.json is stale"
82
+ echo " specification.yaml is newer. Run 'xp-gate check-alignment' to regenerate."
83
+ GATE_5C_STATUS="SKIP"
84
+ return 0
85
+ fi
86
+
87
+ local test_files
88
+ test_files=$(gate_5c_test_files)
89
+ if [ -z "$test_files" ]; then
90
+ # Reported rather than silent: without this line the operator cannot tell
91
+ # "nothing to check" from "the gate is broken" (#585).
92
+ echo " ℹ️ Gate 5c: no test files among the staged changes — nothing to align."
93
+ GATE_5C_STATUS="SKIP"
94
+ return 0
95
+ fi
96
+
97
+ echo " → Checking @test REQ-XXX annotations..."
98
+ local annotation_errors=0 tf req_id found
99
+ while read -r tf; do
100
+ [ -n "$tf" ] || continue
101
+ [ -f "$tf" ] || continue
102
+ while read -r req_id; do
103
+ [ -n "$req_id" ] || continue
104
+ found=$(gate_5c_req_ids "$req_ids_file" | grep -qxF "$req_id" && echo yes || true)
105
+ if [ -z "$found" ]; then
106
+ echo "❌ Gate 5c: $tf — @test $req_id: REQ ID not found in specification.yaml"
107
+ annotation_errors=$((annotation_errors + 1))
108
+ fi
109
+ done < <(gate_5c_annotations "$tf")
110
+ done < <(printf '%s\n' "$test_files")
111
+
112
+ if [ "$annotation_errors" -gt 0 ]; then
113
+ echo ""
114
+ echo "❌ $annotation_errors test annotation(s) reference non-existent REQ IDs — commit BLOCKED"
115
+ echo " Fix: verify the REQ IDs against specification.yaml, or run 'xp-gate check-alignment'"
116
+ GATE_5C_STATUS="BLOCK"
117
+ return 1
118
+ fi
119
+
120
+ # Reverse check: REQs with no annotated test among the staged changes. Visible,
121
+ # non-blocking — a single commit rarely touches every requirement's test.
122
+ local all_reqs annotated_all="" untested=0 spec_req
123
+ all_reqs=$(gate_5c_req_ids "$req_ids_file")
124
+ while read -r tf; do
125
+ [ -n "$tf" ] || continue
126
+ [ -f "$tf" ] || continue
127
+ annotated_all="$annotated_all $(gate_5c_annotations "$tf")"
128
+ done < <(printf '%s\n' "$test_files")
129
+
130
+ while read -r spec_req; do
131
+ [ -n "$spec_req" ] || continue
132
+ if ! printf '%s\n' "$annotated_all" | tr ' ' '\n' | grep -qxF "$spec_req"; then
133
+ untested=$((untested + 1))
134
+ fi
135
+ done < <(printf '%s\n' "$all_reqs")
136
+
137
+ if [ "$untested" -gt 0 ]; then
138
+ echo " ⚠️ [FAIL] Gate 5c: $untested REQ(s) in specification.yaml have no annotated tests"
139
+ echo " This is non-blocking. Run 'xp-gate check-alignment' for the full report."
140
+ fi
141
+
142
+ echo " ✅ @test annotation check complete."
143
+ GATE_5C_STATUS="PASS"
144
+ return 0
145
+ }
@@ -1,12 +1,51 @@
1
1
  #!/usr/bin/env bash
2
2
  # ============================================================================
3
- # Gate 2 duplicate-code helpers (issue #477)
3
+ # Gate 2 duplicate-code helpers (issue #477, #591)
4
4
  #
5
5
  # Extracted from pre-commit so the outcome contract is unit-testable without
6
6
  # executing the hook, and so an installed hook can source it the same way it
7
7
  # sources the other libraries in this directory.
8
8
  # ============================================================================
9
9
 
10
+ # The default ignore set: what Gate 2 must NOT treat as refactorable source when
11
+ # the project expresses no preference (#591).
12
+ #
13
+ # jscpd's own defaults treat .yaml, .txt and .log as scannable formats. On a
14
+ # data-heavy project that reports an exemptions manifest, a golden snapshot and
15
+ # two build logs as "duplicate code" — measured as 48.72% duplication on a
16
+ # fixture that contained no source at all. The number is worse than noise: a
17
+ # reader cannot tell "yaml rows that must repeat" from "two methods that were
18
+ # really copy-pasted", so the honest response is to stop reading Gate 2 and take
19
+ # the real duplicates down with it. The reporter's own tally: 176 clones
20
+ # reported, 6 refactorable.
21
+ #
22
+ # Deliberately NARROW. Every entry is a file class whose duplication is its data
23
+ # shape or its artifact nature, never a refactoring target. Real source
24
+ # extensions are untouched — pinned by a control case, because an ignore set that
25
+ # also swallowed .ts/.java/.py would report a permanent clean and look healthier
26
+ # than before the fix.
27
+ JSCPD_DEFAULT_IGNORE_PATTERNS='(^|/)(node_modules|dist|build|target|coverage|out|\.next|\.nuxt|vendor)/|\.log$|\.txt$|\.(ya?ml)$|golden|baseline\.json$|evidence'
28
+
29
+ # Human-readable form, printed by the gate so the baseline is auditable (#591
30
+ # point 3). Silence about the default is the same defect as silence about a
31
+ # skipped gate: the user cannot answer "what did it scan".
32
+ jscpd_default_ignore_patterns() {
33
+ printf '%s\n' "jscpd default ignore (no jscpd.conf.json): build output (node_modules, dist, build, target, coverage, out, vendor), *.log, *.txt, *.yaml/*.yml, *golden*, *baseline.json, *evidence*"
34
+ }
35
+
36
+ # Is this path covered by the default ignore set? Exit 0 = ignore it.
37
+ #
38
+ # XP_GATE_JSCPD_DEFAULT_IGNORE=0 disables the set entirely: a project that
39
+ # genuinely wants YAML compared must be able to say so, or the default would make
40
+ # some real duplicates unreachable.
41
+ jscpd_is_default_ignored() {
42
+ local _path="$1"
43
+ if [ "${XP_GATE_JSCPD_DEFAULT_IGNORE:-1}" = "0" ]; then
44
+ return 1
45
+ fi
46
+ printf '%s\n' "$_path" | grep -qE "$JSCPD_DEFAULT_IGNORE_PATTERNS"
47
+ }
48
+
10
49
  # Echo the jscpd config the gate should pass, or nothing when the project has
11
50
  # none.
12
51
  #
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # Convert a POSIX path into the form a NATIVE Windows binary can open.
3
+ #
4
+ # #581 / #582: on WSL and in Git-Bash, `node.exe` and `gitleaks.exe` cannot open
5
+ # `/mnt/d/projects/repo/...` or `/d/projects/repo/...`. They resolve a leading `/` to
6
+ # `<current drive>:\`, so the path silently becomes `D:\mnt\d\projects\...` and the
7
+ # module is "not found". The failure then gets reported as a verdict about the USER'S
8
+ # repository:
9
+ #
10
+ # Gate 9 ❌ BLOCKED - Build integrity check failed
11
+ # Fix type errors, broken imports, or package manifest issues
12
+ #
13
+ # Gate 8 ❌ BLOCKED - Secrets detected in staged files
14
+ #
15
+ # Both are wrong, and both are expensive to debug: the message points at the code,
16
+ # the cause is the shell. #582 fixed the Gate 8 instance inline; this library exists so
17
+ # the next caller does not have to re-derive it, and so there is ONE place that knows
18
+ # the converters — not one per gate that happened to notice.
19
+ #
20
+ # Relative paths are deliberately NOT converted. node resolves `scripts/x.cjs` against
21
+ # its own cwd, which it translated correctly; converting would be pointless work and a
22
+ # chance to get it wrong.
23
+
24
+ # Does this host need POSIX -> native conversion at all?
25
+ #
26
+ # True when a native binary is on the far side of the shell: WSL, or MSYS/Git-Bash on
27
+ # Windows. On a plain Linux/macOS host neither converter exists and no conversion is
28
+ # ever wanted.
29
+ #
30
+ # @returns 0 when conversion applies
31
+ native_path_applies() {
32
+ [ -n "${WSL_DISTRO_NAME:-}" ] && return 0
33
+ [ -n "${MSYSTEM:-}" ] && return 0
34
+ # Fallback: a converter being present IS the signal, and covers hosts that set
35
+ # neither marker (some CI images strip MSYSTEM).
36
+ command -v wslpath >/dev/null 2>&1 && return 0
37
+ command -v cygpath >/dev/null 2>&1 && return 0
38
+ return 1
39
+ }
40
+
41
+ # Convert a path to native Windows form, or echo it unchanged when that is not possible.
42
+ #
43
+ # Order matters. `wslpath` is WSL's converter; `cygpath` is MSYS/Git-Bash's. Asking each
44
+ # only on its own host avoids the case where both exist and the wrong one "succeeds"
45
+ # with a path the other binary cannot use.
46
+ #
47
+ # Never fails the caller: a path that cannot be converted is returned as-is, because a
48
+ # gate that aborts here would break on hosts that need no conversion at all. Callers that
49
+ # must know whether conversion happened should compare the input and output.
50
+ #
51
+ # @param {string} path path to convert
52
+ # @returns {string} native form, or the input unchanged
53
+ to_native_path() {
54
+ local _p="${1:-}"
55
+ [ -n "$_p" ] || return 0
56
+ # Only absolute paths are candidates. A relative path is resolved by the child
57
+ # process against a cwd it handles correctly.
58
+ case "$_p" in
59
+ /*) ;;
60
+ *) printf '%s' "$_p"; return 0 ;;
61
+ esac
62
+
63
+ local _converted=""
64
+ if [ -n "${WSL_DISTRO_NAME:-}" ] && command -v wslpath >/dev/null 2>&1; then
65
+ _converted=$(wslpath -w "$_p" 2>/dev/null || true)
66
+ elif command -v cygpath >/dev/null 2>&1; then
67
+ _converted=$(cygpath -w "$_p" 2>/dev/null || true)
68
+ elif command -v wslpath >/dev/null 2>&1; then
69
+ # wslpath without WSL_DISTRO_NAME: still the better guess than nothing.
70
+ _converted=$(wslpath -w "$_p" 2>/dev/null || true)
71
+ fi
72
+
73
+ if [ -n "$_converted" ]; then
74
+ printf '%s' "$_converted"
75
+ else
76
+ printf '%s' "$_p"
77
+ fi
78
+ }
79
+
80
+ # Convert a path only when conversion is needed, and say so when it cannot be done.
81
+ #
82
+ # The stricter sibling: use this where a wrong path produces a MISLEADING verdict rather
83
+ # than a clean failure (Gate 9's "build integrity check failed", Gate 8's "secrets
84
+ # detected"). It prints a warning to stderr when the path is absolute, the host needs
85
+ # conversion, and nothing was converted — the case that silently becomes a false verdict.
86
+ #
87
+ # @param {string} path path to convert
88
+ # @param {string} what human name for the path, used in the warning
89
+ # @returns {string} converted path, or the input unchanged
90
+ to_native_path_or_warn() {
91
+ local _p="${1:-}"
92
+ local _what="${2:-path}"
93
+ local _out
94
+ _out=$(to_native_path "$_p")
95
+ if [ "$_out" = "$_p" ]; then
96
+ case "$_p" in
97
+ /*)
98
+ if native_path_applies; then
99
+ echo " ⚠️ could not convert $_what to native form: $_p" >&2
100
+ echo " a native binary may fail to open it; the failure will look like a code defect" >&2
101
+ fi
102
+ ;;
103
+ esac
104
+ fi
105
+ printf '%s' "$_out"
106
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Score a pre-push gate list, or refuse to.
3
+
4
+ `overall.score` is the most quotable number in a pre-push report, so it has to
5
+ generalise over the gates it claims to summarise (#580, #577).
6
+
7
+ Two shapes must produce no number at all:
8
+
9
+ no gate ran -> 0.0/10 would claim "everything broke"
10
+ some gate ran, but the -> 10.0/10 off a single passing gate would claim
11
+ rest never did "everything is green"
12
+
13
+ The honest answer in both cases is None — "not measured" — because either way the
14
+ number would be a claim the run cannot support. A score is emitted only when the
15
+ gates that ran are a MAJORITY of the gates that exist, and it always travels with
16
+ `score_scope` naming what it covers, so no reader can quote it as global quality.
17
+
18
+ Usage:
19
+ prepush-score.py --status PASS --status SKIP ... # one per gate
20
+ prepush-score.py --report path/to/report.json # patch in place
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
29
+
30
+
31
+ # Statuses grouped by what they say about the run. Kept as data rather than as
32
+ # branches so adding a status is a one-line change and cannot silently fall
33
+ # through to "failed" (which is exactly what DEGRADED used to do, #574).
34
+ FAILED_STATUSES = ("FAIL", "BLOCK")
35
+ # A degradation is a gate that ran but could not fully do its job, so it counts
36
+ # with the warnings: a degraded gate must never be judged a hard failure.
37
+ DEGRADED_STATUSES = ("DEGRADED", "WARN")
38
+
39
+
40
+ def _group_by_disposition(gates: Sequence[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
41
+ """Split the gate list into ran / passed / failed / warned / degraded / skipped."""
42
+ def with_status(statuses: Sequence[str]) -> List[Dict[str, Any]]:
43
+ return [g for g in gates if g.get("status") in statuses]
44
+
45
+ ran = [g for g in gates if g.get("status") != "SKIP"]
46
+ return {
47
+ "ran": ran,
48
+ "passed": with_status(("PASS",)),
49
+ "failed": with_status(FAILED_STATUSES),
50
+ "warned": with_status(("WARN",)),
51
+ "degraded": with_status(DEGRADED_STATUSES),
52
+ "skipped": with_status(("SKIP",)),
53
+ }
54
+
55
+
56
+ def _score_for(ran: Sequence[Dict[str, Any]], passed_count: int, total: int) -> Tuple[Optional[str], str]:
57
+ """The score and the scope it covers, or (None, reason) when it cannot apply.
58
+
59
+ A score speaks for the gates that ran, so it is only emitted when those can
60
+ summarise the report. Two shapes cannot:
61
+
62
+ nothing ran -> 0.0/10 would claim "everything broke"
63
+ fewer than half ran -> 10.0/10 off one passing gate would claim
64
+ "everything is green"
65
+
66
+ Six of twelve is a tie, not an endorsement, hence `* 2 <=`.
67
+ """
68
+ if not ran:
69
+ return None, "not measured"
70
+ if len(ran) * 2 <= total:
71
+ return None, "%d of %d gates is too few to summarise" % (len(ran), total)
72
+ return "%.1f/10" % (10.0 * passed_count / len(ran)), "%d of %d gates" % (len(ran), total)
73
+
74
+
75
+ def compute(gates: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
76
+ """Return the score fields for a gate list.
77
+
78
+ Keys: score (str | None), score_scope (str), gates_run, gates_skipped,
79
+ gates_total, gates_passed, gates_failed, gates_warned, gates_degraded,
80
+ failed_gates, skipped_gates. The rule lives here and nowhere else.
81
+
82
+ `gates_total` and `gates_passed` are counted from the list rather than taken
83
+ from the caller: they used to be a hardcoded 8 and a 1/0 squashed out of the
84
+ verdict, so a report could claim eight gates while listing twelve (#569).
85
+ """
86
+ total = len(gates)
87
+ groups = _group_by_disposition(gates)
88
+ score, scope = _score_for(groups["ran"], len(groups["passed"]), total)
89
+
90
+ return {
91
+ "score": score,
92
+ "score_scope": scope,
93
+ "gates_run": len(groups["ran"]),
94
+ "gates_skipped": len(groups["skipped"]),
95
+ "gates_total": total,
96
+ "gates_passed": len(groups["passed"]),
97
+ "gates_failed": len(groups["failed"]),
98
+ "gates_warned": len(groups["warned"]),
99
+ "gates_degraded": len(groups["degraded"]),
100
+ "failed_gates": [g.get("id") for g in groups["failed"]],
101
+ "skipped_gates": [g.get("id") for g in groups["skipped"]],
102
+ }
103
+
104
+
105
+ def apply_to_report(report: Dict[str, Any]) -> Dict[str, Any]:
106
+ """Patch the score fields into a report's `overall` section, in place."""
107
+ overall = report.setdefault("overall", {})
108
+ overall.update(compute(report.get("gates", [])))
109
+ return report
110
+
111
+
112
+ def main(argv: Optional[Iterable[str]] = None) -> int:
113
+ parser = argparse.ArgumentParser(description="Score a pre-push gate list.")
114
+ parser.add_argument(
115
+ "--status",
116
+ action="append",
117
+ default=[],
118
+ metavar="STATUS",
119
+ help="status of one gate (repeatable, in gate order)",
120
+ )
121
+ parser.add_argument(
122
+ "--report",
123
+ metavar="PATH",
124
+ help="patch the score fields into this report JSON in place",
125
+ )
126
+ args = parser.parse_args(list(argv) if argv is not None else None)
127
+
128
+ if args.report:
129
+ path = Path(args.report)
130
+ report = json.loads(path.read_text(encoding="utf-8"))
131
+ apply_to_report(report)
132
+ path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
133
+ return 0
134
+
135
+ if not args.status:
136
+ parser.error("either --report or at least one --status is required")
137
+
138
+ print(json.dumps(compute([{"status": s} for s in args.status])))
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())