@boyingliu01/xp-gate 0.32.1 → 0.32.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 (69) hide show
  1. package/adapters/gate-3.sh +483 -28
  2. package/adapters/gate-4.sh +20 -0
  3. package/adapters/gate-7.sh +4 -0
  4. package/adapters/powershell.sh +33 -7
  5. package/bin/xp-gate.js +48 -2
  6. package/gate-3.sh +37 -2
  7. package/gate-4.sh +8 -0
  8. package/hooks/gate-3.sh +37 -2
  9. package/hooks/gate-4.sh +8 -0
  10. package/hooks/post-commit +82 -0
  11. package/hooks/pre-commit +19 -0
  12. package/hooks/pre-push +21 -2
  13. package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
  14. package/lib/__tests__/gate-audit.test.ts +144 -0
  15. package/lib/__tests__/shared-utils.test.js +22 -0
  16. package/lib/doctor.js +59 -1
  17. package/lib/gate-audit.ts +215 -7
  18. package/lib/mattpocock-license-verdict.json +1 -1
  19. package/lib/shared-utils.js +4 -1
  20. package/mock-policy/AGENTS.md +3 -3
  21. package/mutation/AGENTS.md +3 -3
  22. package/package.json +1 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  25. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +21 -0
  27. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  28. package/plugins/dsh/README.md +51 -2
  29. package/plugins/dsh/client/client.js +760 -0
  30. package/plugins/dsh/lib/client.js +760 -0
  31. package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
  32. package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
  33. package/plugins/dsh/lib/delphi-settings.js +76 -0
  34. package/plugins/dsh/lib/hooks.d.ts +14 -1
  35. package/plugins/dsh/lib/hooks.d.ts.map +1 -1
  36. package/plugins/dsh/lib/hooks.js +44 -5
  37. package/plugins/dsh/lib/index.d.ts +63 -0
  38. package/plugins/dsh/lib/index.d.ts.map +1 -1
  39. package/plugins/dsh/lib/index.js +126 -21
  40. package/plugins/dsh/package.json +13 -3
  41. package/plugins/dsh/scripts/copy-client.cjs +37 -0
  42. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  43. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  44. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +21 -0
  45. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/plugins/dsh/src/client.test.ts +231 -0
  47. package/plugins/dsh/src/contract.test.ts +25 -1
  48. package/plugins/dsh/src/delphi-settings.test.ts +101 -0
  49. package/plugins/dsh/src/delphi-settings.ts +111 -0
  50. package/plugins/dsh/src/hooks.test.ts +48 -0
  51. package/plugins/dsh/src/hooks.ts +45 -5
  52. package/plugins/dsh/src/index.test.ts +3 -2
  53. package/plugins/dsh/src/index.ts +147 -25
  54. package/plugins/opencode/package.json +1 -1
  55. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  56. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  57. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +21 -0
  58. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  59. package/plugins/qoder/plugin.json +1 -1
  60. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  61. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  62. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +21 -0
  63. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  64. package/principles/AGENTS.md +3 -3
  65. package/scripts/sync-package-content.js +1 -1
  66. package/skills/delphi-review/AGENTS.md +3 -3
  67. package/skills/sprint-flow/AGENTS.md +3 -3
  68. package/skills/sprint-flow/references/phase-3-build.md +21 -0
  69. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -8,21 +8,297 @@
8
8
  2>&1 echo "→ Gate 3: Cyclomatic complexity..."
9
9
  GATE_3_START=$(gate_start_ms)
10
10
  # #537: every non-run records WHY so the report can say why a gate is SKIP.
11
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
12
+ # shellcheck disable=SC2034
11
13
  GATE_3_REASON=""
12
14
 
13
15
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
14
16
  echo "⏭️ SKIPPED - Complexity (documentation project)."
15
17
  GATE_3_STATUS="SKIP"
18
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
19
+ # shellcheck disable=SC2034
16
20
  GATE_3_REASON="documentation_project"
17
21
 
18
22
  elif [ "$PROJECT_LANG" = "powershell" ]; then
19
- echo "ℹ️ No PowerShell Clean Code / SOLID tool available"
20
- echo "⏭️ SKIPPED - Complexity (no PowerShell tool)"
21
- GATE_3_STATUS="SKIP"
22
- # #537: a PowerShell-primary repo was invisible to this dimension while the
23
- # score implied full coverage — say exactly why nothing ran.
24
- GATE_3_REASON="no_powershell_tool"
25
-
23
+ # #537/#598: this arm used to be a hard SKIP with "no PowerShell tool". That
24
+ # was true of lizard (it cannot parse .ps1) but NOT of the platform:
25
+ # PowerShell's own AST gives an exact decision-point count, which is the same
26
+ # quantity lizard measures for other languages. A PowerShell-primary repo
27
+ # therefore had a permanently unmeasured quality dimension while the score
28
+ # implied full coverage.
29
+ #
30
+ # The measurement lives in lib/Measure-PowerShellComplexity.ps1 because the
31
+ # hook cannot know a project's refactor history; the adapter only runs it and
32
+ # translates the verdict.
33
+ #
34
+ # The threshold resolution is duplicated from the lizard arm on purpose: the
35
+ # arms are read independently of each other, and a shared helper here would
36
+ # make the PowerShell path depend on the code path it replaces.
37
+ _gate3_ps_threshold() {
38
+ local default=5 val
39
+ if [ -f ".xp-gate/ccn-threshold" ]; then
40
+ val=$(sed -n '1s/[[:space:]]*$//p' ".xp-gate/ccn-threshold" 2>/dev/null || true)
41
+ case "$val" in
42
+ ''|*[!0-9]*) echo "WARN - invalid .xp-gate/ccn-threshold value '${val}' (expected a positive integer), falling back to ${default}" >&2 ;;
43
+ 0) echo "WARN - invalid .xp-gate/ccn-threshold value '0' (expected a positive integer), falling back to ${default}" >&2 ;;
44
+ *) echo "$val"; return 0 ;;
45
+ esac
46
+ fi
47
+ val=${XP_GATE_CCN_THRESHOLD:-}
48
+ case "$val" in
49
+ '') echo "$default" ;;
50
+ *[!0-9]*) echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '${val}' (expected a positive integer), falling back to ${default}" >&2; echo "$default" ;;
51
+ 0) echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '0' (expected a positive integer), falling back to ${default}" >&2; echo "$default" ;;
52
+ *) echo "$val" ;;
53
+ esac
54
+ }
55
+
56
+ # Resolve a PowerShell engine LOCALLY.
57
+ #
58
+ # This arm must NOT call _detect_pwsh, which is defined in
59
+ # adapters/powershell.sh: the hook sources that adapter inside a SUBSHELL for
60
+ # Gate 1 --
61
+ # PS_G1_OUTPUT=$(source "$POWERSHELL_ADAPTER"; run_static_analysis)
62
+ # -- so its functions never reach this gate. Depending on it printed
63
+ # "no PowerShell engine found" on a machine with both pwsh 7 AND PS 5.1
64
+ # installed, i.e. the verdict was about bash scoping, not about the engine.
65
+ # A gate must not depend on another gate having sourced a library first.
66
+ _gate3_detect_pwsh() {
67
+ if command -v pwsh >/dev/null 2>&1; then echo pwsh; return 0; fi
68
+ if command -v powershell.exe >/dev/null 2>&1; then echo powershell.exe; return 0; fi
69
+ local _c
70
+ for _c in \
71
+ "${LOCALAPPDATA:-$HOME/AppData/Local}/Microsoft/WindowsApps/pwsh.exe" \
72
+ "/c/Program Files/PowerShell/7/pwsh.exe" \
73
+ "/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; do
74
+ [ -x "$_c" ] && { echo "$_c"; return 0; }
75
+ done
76
+ return 1
77
+ }
78
+
79
+ PS_CC_SCRIPT="${AUDIT_SCRIPT_DIR}/lib/Measure-PowerShellComplexity.ps1"
80
+ if [ ! -f "$PS_CC_SCRIPT" ]; then
81
+ echo "ℹ️ No PowerShell complexity tool available"
82
+ echo " (expected at lib/Measure-PowerShellComplexity.ps1)"
83
+ echo "⏭️ SKIPPED - Complexity (no PowerShell tool)"
84
+ GATE_3_STATUS="SKIP"
85
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
86
+ # shellcheck disable=SC2034
87
+ GATE_3_REASON="no_powershell_tool"
88
+ else
89
+ # #581: pwsh.exe is a NATIVE binary and cannot open the POSIX path this hook
90
+ # holds (`/c/Users/...` or `/tmp/...`). Handed one unchanged it resolves the
91
+ # leading `/` to `<current drive>:\`, silently measures zero files, and the
92
+ # gate then reports "no functions" -- an environment fault reported as a
93
+ # verdict about the repository. Convert the script path once, here.
94
+ PS_CC_SCRIPT_NATIVE="$PS_CC_SCRIPT"
95
+ if command -v to_native_path >/dev/null 2>&1; then
96
+ PS_CC_SCRIPT_NATIVE=$(to_native_path "$PS_CC_SCRIPT" 2>/dev/null || echo "$PS_CC_SCRIPT")
97
+ fi
98
+ PS_CC_SCRIPT="$PS_CC_SCRIPT_NATIVE"
99
+ PS_CC_PWSH=$(_gate3_detect_pwsh 2>/dev/null || true)
100
+ if [ -z "$PS_CC_PWSH" ]; then
101
+ echo "⚠️ WARN - no PowerShell engine found; complexity not measured"
102
+ GATE_3_STATUS="WARN"
103
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
104
+ # shellcheck disable=SC2034
105
+ GATE_3_REASON="no_powershell_engine"
106
+ else
107
+ PS_CC_THRESHOLD=$(_gate3_ps_threshold)
108
+ PS_ADDED=$(git diff --cached --name-only --diff-filter=A 2>/dev/null | grep -E '\.ps(m)?1$' || true)
109
+ PS_BASELINE=".xp-gate-powershell-ccn-baseline.json"
110
+
111
+ if [ -z "$PS_ADDED" ]; then
112
+ # No added files, but modified ones can still get MORE complex. That is
113
+ # what the ratchet baseline is for; without a baseline there is nothing
114
+ # to compare, and silently passing would be the #584 failure mode.
115
+ PS_TOUCHED=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.ps(m)?1$' || true)
116
+ PS_TOUCHED=$(printf '%s\n' "$PS_TOUCHED" | grep -vE '(^|/)tests/' || true)
117
+ if [ -z "$PS_TOUCHED" ] || [ ! -f "$PS_BASELINE" ]; then
118
+ echo "⏭️ SKIPPED - Complexity (no added PowerShell files to measure)."
119
+ GATE_3_STATUS="SKIP"
120
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
121
+ # shellcheck disable=SC2034
122
+ GATE_3_REASON="no_added_powershell_files"
123
+ else
124
+ PS_CC_ARGS=()
125
+ for _f in $PS_TOUCHED; do PS_CC_ARGS[${#PS_CC_ARGS[@]}]="'$_f'"; done
126
+
127
+ # #598: the ratchet branch must use the SAME threshold as the baseline
128
+ # collector. -Threshold 1 used to make every function with CCN >= 2
129
+ # print OVER, so a brand-new well-factored function (CCN 2-4, below the
130
+ # real threshold) was diffed against a baseline that can never contain
131
+ # it and reported as a "regression": the gate blocked exactly the shape
132
+ # it exists to encourage. -List additionally emits the full population,
133
+ # so the comparison covers functions that are below the threshold on
134
+ # both sides.
135
+ PS_CC_OUT=$("$PS_CC_PWSH" -NoProfile -NonInteractive -ExecutionPolicy Bypass \
136
+ -Command "& '$PS_CC_SCRIPT' -Path @($(IFS=','; echo "${PS_CC_ARGS[*]}")) -Threshold ${PS_CC_THRESHOLD} -List" 2>&1 || true)
137
+
138
+ PS_CC_MEASURED=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^functions_measured=//p' | tail -1)
139
+ if [ -z "$PS_CC_MEASURED" ] || [ "${PS_CC_MEASURED:-0}" -eq 0 ]; then
140
+ echo "⏭️ SKIPPED - Complexity (no functions in changed PowerShell files)."
141
+ GATE_3_STATUS="SKIP"
142
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
143
+ # shellcheck disable=SC2034
144
+ GATE_3_REASON="no_functions_found"
145
+ else
146
+ PS_CC_NODE=""
147
+ if command -v node >/dev/null 2>&1; then PS_CC_NODE="node"; fi
148
+ if [ -z "$PS_CC_NODE" ]; then
149
+ echo "ℹ️ node unavailable — complexity measured (${PS_CC_MEASURED} function(s)) but ratchet not compared"
150
+ echo "✅ PASSED - PowerShell complexity measured (ratchet unavailable)."
151
+ GATE_3_DETAIL="mode=measured_no_ratchet"
152
+ else
153
+ # Compare every measured function against the baseline. A function
154
+ # absent from the baseline is NEW: it is only a regression if it is
155
+ # above the threshold, which is exactly what the old -Threshold 1
156
+ # comparison got wrong. An unreadable baseline is ERR, not a pass.
157
+ PS_CC_VERDICT=$(printf '%s\n' "$PS_CC_OUT" | "$PS_CC_NODE" -e '
158
+ let raw = "";
159
+ process.stdin.on("data", (c) => { raw += c; });
160
+ process.stdin.on("end", () => {
161
+ const fs = require("fs");
162
+ const fsPath = process.argv[1];
163
+ const threshold = Number(process.argv[2]);
164
+ let baseline;
165
+ try {
166
+ baseline = JSON.parse(fs.readFileSync(fsPath, "utf8"));
167
+ } catch (e) {
168
+ console.log("ERR " + e.message);
169
+ return;
170
+ }
171
+ const known = new Map();
172
+ const entries = baseline.functions || baseline.entries || baseline;
173
+ if (Array.isArray(entries)) {
174
+ for (const e of entries) {
175
+ if (!e || !e.file || !e.name) continue;
176
+ known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
177
+ }
178
+ }
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
+ }
215
+ const seen = new Set();
216
+ const regressions = [];
217
+ const improved = [];
218
+ for (const line of raw.split(/\r?\n/)) {
219
+ const m = line.match(/^(?:OVER|FUNC) (.+):(\d+) (\S+) ccn=(\d+)$/);
220
+ if (!m) continue;
221
+ const key = norm(m[1]) + "::" + m[3];
222
+ const now = Number(m[4]);
223
+ seen.add(key);
224
+ const was = lookup(m[1], m[3]);
225
+ if (was === null) {
226
+ if (now > threshold) {
227
+ regressions.push(m[1] + ":" + m[2] + " " + m[3] + " new function at ccn=" + now);
228
+ }
229
+ continue;
230
+ }
231
+ if (now > was) {
232
+ regressions.push(m[1] + ":" + m[2] + " " + m[3] + " ccn " + was + " -> " + now);
233
+ } else if (now < was) {
234
+ improved.push(m[1] + ":" + m[2] + " " + m[3] + " ccn " + was + " -> " + now);
235
+ }
236
+ }
237
+ for (const r of regressions) console.log("REGRESSION " + r);
238
+ for (const i of improved) console.log("IMPROVED " + i);
239
+ if (regressions.length > 0) {
240
+ console.log("FAIL " + regressions.length + " function(s) increased cyclomatic complexity vs " + fsPath + ".");
241
+ }
242
+ });
243
+ ' "$PS_BASELINE" "$PS_CC_THRESHOLD" 2>&1 || true)
244
+
245
+ if printf '%s\n' "$PS_CC_VERDICT" | grep -q '^FAIL '; then
246
+ PS_CC_REG=$(printf '%s\n' "$PS_CC_VERDICT" | sed -n 's/^FAIL //p' | tail -1)
247
+ printf '%s\n' "$PS_CC_VERDICT" | grep '^REGRESSION ' | sed 's/^REGRESSION / ❌ /'
248
+ echo "❌ BLOCKED - ${PS_CC_REG}"
249
+ GATE_3_STATUS="BLOCK"
250
+ GATE_3_REASON="ccn_ratchet_regression"
251
+ exit 1
252
+ elif printf '%s\n' "$PS_CC_VERDICT" | grep -q '^ERR '; then
253
+ echo "⚠️ WARN - complexity ratchet baseline unreadable: $(printf '%s\n' "$PS_CC_VERDICT" | sed -n 's/^ERR //p' | head -1)"
254
+ GATE_3_STATUS="WARN"
255
+ GATE_3_REASON="baseline_unreadable"
256
+ else
257
+ PS_CC_IMPROVED=$(printf '%s\n' "$PS_CC_VERDICT" | grep -c '^IMPROVED ' || true)
258
+ printf '%s\n' "$PS_CC_VERDICT" | grep '^IMPROVED ' | sed 's/^IMPROVED / 📉 /'
259
+ echo "✅ PASSED - PowerShell complexity within ratchet (${PS_CC_MEASURED} function(s), ${PS_CC_IMPROVED} improved, threshold ${PS_CC_THRESHOLD})."
260
+ GATE_3_STATUS="PASS"
261
+ GATE_3_DETAIL="powerShell_ratchet improved=${PS_CC_IMPROVED}"
262
+ fi
263
+ fi
264
+ fi
265
+ fi
266
+ else
267
+ # Freshly added files: this arm has no history to compare, so it judges
268
+ # each file's functions against the threshold. A file that is merely
269
+ # being touched keeps its history (that is what the baseline is for);
270
+ # measuring only ADDED files means the arm can never retroactively block
271
+ # a cleanup commit.
272
+ PS_CC_ARGS=()
273
+ for _f in $PS_ADDED; do PS_CC_ARGS[${#PS_CC_ARGS[@]}]="'$_f'"; done
274
+ PS_CC_OUT=$("$PS_CC_PWSH" -NoProfile -NonInteractive -ExecutionPolicy Bypass \
275
+ -Command "& '$PS_CC_SCRIPT' -Path @($(IFS=','; echo "${PS_CC_ARGS[*]}")) -Threshold ${PS_CC_THRESHOLD}" 2>&1 || true)
276
+ PS_CC_MEASURED=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^functions_measured=//p' | tail -1)
277
+ PS_CC_OVER=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^over_threshold=//p' | tail -1)
278
+ PS_CC_MAX=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^max_ccn=//p' | tail -1)
279
+ if [ -z "$PS_CC_MEASURED" ] || [ "${PS_CC_MEASURED:-0}" -eq 0 ]; then
280
+ echo "⏭️ SKIPPED - Complexity (no functions in added PowerShell files)."
281
+ GATE_3_STATUS="SKIP"
282
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
283
+ # shellcheck disable=SC2034
284
+ GATE_3_REASON="no_functions_found"
285
+ elif [ "${PS_CC_OVER:-0}" -gt 0 ]; then
286
+ echo " Measured ${PS_CC_MEASURED} function(s) via AST; max CCN ${PS_CC_MAX} (threshold ${PS_CC_THRESHOLD})"
287
+ printf '%s\n' "$PS_CC_OUT" | grep '^OVER ' | sed 's/^/ /'
288
+ echo "❌ BLOCKED - ${PS_CC_OVER} newly added function(s) with CCN > ${PS_CC_THRESHOLD}."
289
+ echo "Refactor high-complexity functions, or raise the threshold deliberately."
290
+ GATE_3_STATUS="BLOCK"
291
+ GATE_3_REASON="ccn_over_threshold"
292
+ exit 1
293
+ else
294
+ echo "✅ PASSED - All added PowerShell functions within complexity threshold (${PS_CC_THRESHOLD})."
295
+ echo " Measured ${PS_CC_MEASURED} function(s) via AST; max CCN ${PS_CC_MAX}."
296
+ GATE_3_STATUS="PASS"
297
+ fi
298
+ fi
299
+ fi
300
+ fi
301
+
26
302
  else
27
303
  # S2 (#507): three-level CCN threshold override --
28
304
  # .xp-gate/ccn-threshold (positive integer) > XP_GATE_CCN_THRESHOLD > 5.
@@ -77,40 +353,209 @@ else
77
353
 
78
354
  # Get changed source files for complexity check (includes test files)
79
355
  CC_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx|py|go|java|swift|cpp|c|hpp|h|m|mm|kt)$' || true)
80
-
356
+
357
+ # Grade exactly the population the baseline was written from. The two must
358
+ # match or the ratchet reports pre-existing code as "new", which is a false
359
+ # BLOCK no repair can clear (the exact failure #495 Round 3 called out, in a
360
+ # different place). scripts/ccn-report.cjs --write-baseline records only
361
+ # bucket === 'production' rows; its bucketFor() classifies two other buckets:
362
+ # mirror src/npm-package/**, plugins/*/src/** — regenerated by prepack
363
+ # from a canonical source that IS graded here. Editing a mirror
364
+ # is reverted at the next publish, so grading it cannot be acted
365
+ # on (doctorDelphi CCN=106 blocked every src/npm-package commit,
366
+ # on HEAD too — no baseline entry could ever exist for it).
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.
370
+ # Both prefixes and the test pattern mirror ccn-report.cjs so the gate and
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.
385
+ CC_FILES=$(echo "$CC_FILES" | grep -vE \
386
+ '^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests?__/|(^|/)(tests?)/|\.test\.[cm]?[jt]sx?$|(^|/)test_[^/]*\.py$|_test\.(py|go)$|Test(s)?\.java$' || true)
387
+
81
388
  if [ -n "$CC_FILES" ]; then
82
389
  echo "Checking complexity for source files..."
83
390
 
84
- # Run lizard with CCN threshold
85
- # One path per line, into an array. Unquoted $CC_FILES relied on word
86
- # splitting, which also splits any path containing a space -- handing
87
- # lizard two bogus paths and checking neither (#457 defect class, same
88
- # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
89
- # collapse the list into one argument. An array does both.
90
- CC_FILES_ARGS=()
91
- while IFS= read -r _cc_file; do
92
- [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
93
- done <<< "$CC_FILES"
94
- CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
95
-
96
- # Parse warning count from the summary table: "Warning cnt 8"
97
- # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
98
- CC_WARNINGS=$(echo "$CC_OUTPUT" | grep "^Warning cnt" | awk '{print $NF}' | tr -d '[:space:]' | sed 's/[^0-9]//g' || true)
99
- CC_WARNINGS=${CC_WARNINGS:-0}
100
-
101
- if [ "$CC_WARNINGS" -gt 0 ]; then
391
+ # Run lizard in chunks. Two reasons, both load-bearing:
392
+ # - One path per line, into an array. Unquoted $CC_FILES relied on word
393
+ # splitting, which also splits any path containing a space -- handing
394
+ # lizard two bogus paths and checking neither (#457 defect class, same
395
+ # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
396
+ # collapse the list into one argument. An array does both.
397
+ # - Chunked instead of one argv. Windows caps a single command line at
398
+ # 32,767 characters, and under `--all` the population is the whole tree
399
+ # (1,000+ sources on a mid-sized repo). The oversized argv never reached
400
+ # lizard, nothing was printed, and the gate graded an empty CSV as if the
401
+ # tree were clean. XP_GATE_LIZARD_ARGV_BUDGET lets a probe force a split
402
+ # without needing a huge fixture.
403
+ LIZARD_ARGV_BUDGET="${XP_GATE_LIZARD_ARGV_BUDGET:-8000}"
404
+ lizard_batches() {
405
+ # stdin: one path per line. stdout: lizard output, batches concatenated.
406
+ local _batch=() _size=0 _path
407
+ while IFS= read -r _path; do
408
+ [ -n "$_path" ] || continue
409
+ if [ "${#_batch[@]}" -gt 0 ] && [ $((_size + ${#_path} + 1)) -gt "$LIZARD_ARGV_BUDGET" ]; then
410
+ "$LIZARD_PATH" "$@" "${_batch[@]}" 2>/dev/null || true
411
+ _batch=()
412
+ _size=0
413
+ fi
414
+ _batch+=("$_path")
415
+ _size=$((_size + ${#_path} + 1))
416
+ done
417
+ if [ "${#_batch[@]}" -gt 0 ]; then
418
+ "$LIZARD_PATH" "$@" "${_batch[@]}" 2>/dev/null || true
419
+ fi
420
+ return 0
421
+ }
422
+ CC_OUTPUT=$(printf '%s\n' "$CC_FILES" | lizard_batches -C "$CCN_THRESHOLD" 2>&1)
423
+
424
+ # REQ-D (sprint-20261018-quality): baseline ratchet. When
425
+ # .ccn-baseline.json exists it replaces the flat threshold verdict:
426
+ # recorded functions may not get WORSE (strictly greater blocks), new
427
+ # functions face the live thresholds, improvements only hint. The
428
+ # legacy path below stays byte-for-byte for repos without a baseline.
429
+ if [ -f ".ccn-baseline.json" ]; then
430
+ # #544: lizard's raw CCN is not what the baseline was recorded with.
431
+ # The baseline comes from scripts/ccn-report.cjs, which re-derives every
432
+ # above-threshold CCN from brace-balanced source (lizard's TS parser
433
+ # loses braces on generic-arrow fields and charges neighbours). Feeding
434
+ # the ratchet raw numbers compared two different engines — a function
435
+ # at its recorded ceiling read as "worse" purely from span inflation.
436
+ # Route the CSV through the same verifier; on rows it cannot verify the
437
+ # helper exits non-zero, and this gate fails closed instead of passing
438
+ # an unvalidated number.
439
+ CC_CSV=$(printf '%s\n' "$CC_FILES" | lizard_batches --csv)
440
+ CC_REPORT="$(git rev-parse --show-toplevel 2>/dev/null)/scripts/ccn-report.cjs"
441
+ # Fail closed when the verifier is unavailable. Previously this fell
442
+ # through and graded the RAW lizard CSV, while CI hard-fails on the same
443
+ # condition — so the hook and CI could reach opposite verdicts on
444
+ # identical code, and the hook silently compared baseline numbers
445
+ # (recorded by ccn-report's brace-verified engine) against an engine it
446
+ # no longer had. Agreeing with CI is the point of having both.
447
+ if [ ! -f "$CC_REPORT" ] || ! command -v node >/dev/null 2>&1; then
448
+ echo "❌ BLOCKED - complexity verifier unavailable (scripts/ccn-report.cjs needs node)."
449
+ echo "CI fails on this same condition; refusing to grade unverified CCN locally."
450
+ GATE_3_DETAIL="mode=ratchet verifier=MISSING"
451
+ exit 1
452
+ fi
453
+ if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
454
+ _cc_verify_failed=0
455
+ CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
456
+ if [ "$_cc_verify_failed" -ne 0 ]; then
457
+ echo "❌ BLOCKED - complexity could not be span-verified; refusing to grade unverified CCN."
458
+ GATE_3_DETAIL="mode=ratchet verify=FAILED"
459
+ exit 1
460
+ fi
461
+ CC_CSV="$CC_CSV_VERIFIED"
462
+ fi
463
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
464
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
465
+ # Fail closed when the ratchet library is missing. Sourcing a nonexistent
466
+ # file only prints "No such file or directory" and leaves
467
+ # evaluate_ccn_ratchet undefined; the verdict then came back empty, every
468
+ # counter read 0 and the gate printed "✅ PASSED" — a CCN regression graded
469
+ # green with nothing compared. The installer never created this path
470
+ # (copyAdapters skipped hooks/lib), so this was the normal state after a
471
+ # global install, not an exotic one.
472
+ if [ ! -f "$RATCHET_LIB" ]; then
473
+ echo "❌ BLOCKED - complexity ratchet library unavailable (ccn-ratchet.sh)."
474
+ echo "Expected at \$GATE_DIR/lib/ccn-ratchet.sh or <repo>/githooks/lib/ccn-ratchet.sh."
475
+ echo "Reinstall with 'xp-gate init --global' (or 'xp-gate update-hooks') to restore it."
476
+ GATE_3_DETAIL="mode=ratchet ratchet=MISSING"
477
+ exit 1
478
+ fi
479
+ # shellcheck source=/dev/null
480
+ . "$RATCHET_LIB"
481
+ # NOTE: the thresholds must be plain assignments BEFORE the pipe. As a
482
+ # prefix to a variable assignment (`T=1 V=$(... | fn)`) they land in the
483
+ # assignment's subshell, so the function sees no thresholds AND reads an
484
+ # empty stdin — the verdict comes back empty and Gate 3 passes on zero
485
+ # rows (measured while wiring #544: same input passed or blocked
486
+ # depending only on where the prefix sat).
487
+ # Consumed by evaluate_ccn_ratchet in the sourced lib, not here.
488
+ # shellcheck disable=SC2034
489
+ CCN_WARN_THRESHOLD="$CCN_THRESHOLD"
490
+ # shellcheck disable=SC2034
491
+ CCN_BLOCK_THRESHOLD=$((CCN_THRESHOLD * 2))
492
+ RATCHET_VERDICT=$(printf '%s\n' "$CC_CSV" | evaluate_ccn_ratchet ".ccn-baseline.json")
493
+ CC_VIOLATIONS=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^BLOCK ' || true)
494
+ CC_LOWER=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^LOWER ' || true)
495
+ printf '%s\n' "$RATCHET_VERDICT" | grep -v '^OK ' || true
496
+ if [ "${CC_LOWER:-0}" -gt 0 ]; then
497
+ echo "ℹ️ $CC_LOWER function(s) improved below baseline — refresh with: node scripts/ccn-report.cjs --write-baseline"
498
+ fi
499
+ GATE_3_DETAIL="mode=ratchet"
500
+ if [ "${CC_VIOLATIONS:-0}" -gt 0 ]; then
501
+ echo ""
502
+ echo "❌ BLOCKED - $CC_VIOLATIONS function(s) violate the complexity ratchet (.ccn-baseline.json)."
503
+ echo "Revert the increase, or refactor; refresh the baseline only via ccn-baseline-refresh commits."
504
+ exit 1
505
+ else
506
+ echo "✅ PASSED - Complexity within ratchet baseline."
507
+ GATE_3_STATUS="PASS"
508
+ fi
509
+ else
510
+ # #543: count from lizard's machine-readable CSV, never its tables.
511
+ #
512
+ # The previous parser grepped '^Warning cnt' for a summary row. Two things
513
+ # made that never match: "Warning cnt" is a column header in the MIDDLE of
514
+ # a line ("Total nloc Avg.NLOC ... Warning cnt Fun Rt nloc Rt"), and
515
+ # lizard omits the summary block entirely when given explicit file
516
+ # arguments -- which is how this gate always invokes it. CC_WARNINGS was
517
+ # therefore always 0 and the flat-threshold path could not block anything.
518
+ #
519
+ # --csv has a stable column order (NLOC,CCN,token,PARAM,length,location,
520
+ # file,name,signature,...), so the verdict comes from the same numbers
521
+ # lizard uses, independent of version and table formatting. --csv here is
522
+ # the flat-threshold run; the ratchet branch above builds its own.
523
+ CC_CSV_FLAT=$(printf '%s\n' "$CC_FILES" | lizard_batches --csv -C "$CCN_THRESHOLD")
524
+ CC_WARNINGS=$(printf '%s\n' "$CC_CSV_FLAT" | awk -F, -v t="$CCN_THRESHOLD" '
525
+ NF > 1 && $2 + 0 > t + 0 { c++ }
526
+ END { print c + 0 }
527
+ ')
528
+ # A lizard that produced no parsable row must not read as "clean". Without
529
+ # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
530
+ # reports PASSED on zero analysed functions -- the same silent-success
531
+ # failure this issue is about.
532
+ if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
533
+ END { exit(found ? 0 : 1) }'; then
534
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
535
+ GATE_3_STATUS="WARN"
536
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
537
+ # shellcheck disable=SC2034
538
+ GATE_3_REASON="lizard_output_unparsable"
539
+ elif [ "$CC_WARNINGS" -gt 0 ]; then
102
540
  echo "$CC_OUTPUT"
103
541
  echo ""
104
542
  echo "❌ BLOCKED - $CC_WARNINGS functions with CCN > $CCN_THRESHOLD found."
105
543
  echo "Refactor high-complexity functions to keep below $CCN_THRESHOLD complexity."
544
+ GATE_3_STATUS="BLOCK"
545
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
546
+ # shellcheck disable=SC2034
547
+ GATE_3_REASON="ccn_over_threshold"
106
548
  exit 1
107
549
  else
108
550
  echo "✅ PASSED - All functions within complexity threshold ($CCN_THRESHOLD)."
109
551
  GATE_3_STATUS="PASS"
110
552
  fi
553
+ fi
111
554
  else
112
555
  # #589: "no source files to check" described an empty PROJECT when the real
113
- # cause is an empty CHANGED_FILES. The old wording read like a pass.
556
+ # cause is an empty CHANGED_FILES — an empty DIFF, or a `--all` run whose
557
+ # tree genuinely has no sources. The old wording read like a pass, which is
558
+ # why the reporter worked around the gate by running lizard by hand.
114
559
  if [ -n "${GATE_SCOPE:-}" ] && [ "$GATE_SCOPE" = "all" ]; then
115
560
  echo "⏭️ SKIPPED - Complexity: no tracked source files in this repository (scope: --all, whole tree)."
116
561
  else
@@ -118,6 +563,8 @@ else
118
563
  echo " Run 'xp-gate check . --all' to scan the whole tree."
119
564
  fi
120
565
  GATE_3_STATUS="SKIP"
566
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
567
+ # shellcheck disable=SC2034
121
568
  GATE_3_REASON="no_source_files_changed"
122
569
  fi
123
570
  else
@@ -125,7 +572,15 @@ else
125
572
  echo " Install with: pip install --user lizard"
126
573
  echo " Gate 3: Complexity check (WARN, tool not available)"
127
574
  GATE_3_STATUS="WARN"
575
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
576
+ # shellcheck disable=SC2034
128
577
  GATE_3_REASON="tool_not_installed"
129
578
  fi
130
579
  fi
131
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"
580
+ # The audit's issues_found comes from whichever mode actually ran. In ratchet
581
+ # mode the count lives in CC_VIOLATIONS (functions worse than their recorded
582
+ # baseline); the flat-threshold mode parses CC_WARNINGS from lizard's own table.
583
+ # Reading only CC_WARNINGS meant every ratchet block recorded issues_found=0 —
584
+ # the gate failed while the journal claimed nothing was found.
585
+ CC_ISSUES="${CC_VIOLATIONS:-${CC_WARNINGS:-0}}"
586
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "$CC_ISSUES" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}${GATE_3_DETAIL:+ $GATE_3_DETAIL}"
@@ -56,11 +56,15 @@ GATE_4_START=$(gate_start_ms)
56
56
  GATE_4_STATUS=""
57
57
  # #537: every non-run records WHY (no files / tool missing / unsupported lang)
58
58
  # so the quality report can distinguish "not applicable" from "not covered".
59
+ # Consumed by the sourcing parent, not here (see #548).
60
+ # shellcheck disable=SC2034
59
61
  GATE_4_REASON=""
60
62
 
61
63
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
62
64
  echo "⏭️ SKIPPED - Principles check (documentation project)."
63
65
  GATE_4_STATUS="SKIP"
66
+ # Consumed by the sourcing parent, not here (see #548).
67
+ # shellcheck disable=SC2034
64
68
  GATE_4_REASON="documentation_project"
65
69
 
66
70
  else
@@ -103,6 +107,14 @@ else
103
107
  if [ -n "$PRINCIPLES_DIR" ]; then
104
108
  echo "Checking Clean Code + SOLID principles..."
105
109
 
110
+ # #581/#582 class: run_tsx drives a NATIVE node/tsx binary. A POSIX
111
+ # path like $HOME/.config/... reaches it as the wrong drive prefix and
112
+ # the checker is "not found", which the exit>=2 branch then reported
113
+ # as SKIPPED. Convert once here so both invocations agree.
114
+ if declare -F to_native_path >/dev/null 2>&1; then
115
+ PRINCIPLES_DIR="$(to_native_path "$PRINCIPLES_DIR")"
116
+ fi
117
+
106
118
  # AC-507-04-02: say what a Java commit is actually covered for, instead
107
119
  # of implying a full 15-rule check (#507). Printed before the run so it
108
120
  # appears on both the PASS and the FAIL path.
@@ -156,6 +168,8 @@ else
156
168
  echo "⚠️ Warning: Principles checker execution failed"
157
169
  echo "⏭️ SKIPPED - Principles check (execution issue)"
158
170
  GATE_4_STATUS="SKIP"
171
+ # Consumed by the sourcing parent, not here (see #548).
172
+ # shellcheck disable=SC2034
159
173
  GATE_4_REASON="checker_execution_error"
160
174
  rm -f "$PRINCIPLES_JSON"
161
175
  else
@@ -191,17 +205,23 @@ else
191
205
  echo "ℹ️ npx/tsx not available - skipping principles check"
192
206
  echo "⏭️ SKIPPED - Principles check (no Node.js/tsx)"
193
207
  GATE_4_STATUS="SKIP"
208
+ # Consumed by the sourcing parent, not here (see #548).
209
+ # shellcheck disable=SC2034
194
210
  GATE_4_REASON="tool_not_installed"
195
211
  fi
196
212
  else
197
213
  echo "ℹ️ Principles checker not found in project - skipping"
198
214
  echo "⏭️ SKIPPED - Principles check (checker not in project)"
199
215
  GATE_4_STATUS="SKIP"
216
+ # Consumed by the sourcing parent, not here (see #548).
217
+ # shellcheck disable=SC2034
200
218
  GATE_4_REASON="checker_not_found"
201
219
  fi
202
220
  else
203
221
  echo "⏭️ SKIPPED - Principles check (no matching source files changed)"
204
222
  GATE_4_STATUS="SKIP"
223
+ # Consumed by the sourcing parent, not here (see #548).
224
+ # shellcheck disable=SC2034
205
225
  GATE_4_REASON="no_matching_files_changed"
206
226
  fi
207
227
  fi
@@ -12,11 +12,15 @@ GATE_7_START=$(gate_start_ms)
12
12
  IAC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E "\.(tf|yaml|yml)$|Dockerfile" || true)
13
13
  # #536: scanned_files makes a trivial PASS (nothing to scan) auditable in the
14
14
  # quality report instead of reading like a covered security check.
15
+ # Consumed by the sourcing parent, not here (see #548).
16
+ # shellcheck disable=SC2034
15
17
  GATE_7_SCANNED_FILES=0
16
18
  if [ -z "$IAC_FILES" ]; then
17
19
  echo "✅ PASSED - No IaC files detected in changes."
18
20
  GATE_7_STATUS="PASS"
19
21
  else
22
+ # Consumed by the sourcing parent, not here (see #548).
23
+ # shellcheck disable=SC2034
20
24
  GATE_7_SCANNED_FILES=$(printf '%s\n' "$IAC_FILES" | sed -n '/./p' | wc -l | tr -d '[:space:]')
21
25
  # Run IaC adapter. Resolution follows the documented 3-tier convention
22
26
  # (adapter-common.sh): global ~/.config/xp-gate/adapters -> project