@boyingliu01/xp-gate 0.33.0 → 0.33.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.
- package/gate-3.sh +48 -7
- package/gate-8.sh +32 -3
- package/gate-9.sh +14 -2
- package/gates/common-process.ts +178 -0
- package/gates/common.ts +208 -0
- package/gates/gate-3.ts +144 -0
- package/gates/gate-7.ts +147 -0
- package/gates/gate-8.ts +92 -0
- package/gates/gate-9.ts +269 -0
- package/gates/pbt-detect.ts +163 -0
- package/gates/python-health.ts +266 -0
- package/gates/test-layers.ts +312 -0
- package/hooks/gate-3.sh +48 -7
- package/hooks/gate-8.sh +32 -3
- package/hooks/gate-9.sh +14 -2
- package/hooks/pre-commit +51 -2
- package/lib/__tests__/check-normalize-exit.test.js +24 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/__tests__/uninstall.test.js +18 -0
- package/lib/check.js +27 -4
- package/lib/gate-runner.js +6 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-utils.js +6 -1
- package/lib/uninstall.js +21 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +2 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/gate-3.sh
CHANGED
|
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
|
|
|
172
172
|
const entries = baseline.functions || baseline.entries || baseline;
|
|
173
173
|
if (Array.isArray(entries)) {
|
|
174
174
|
for (const e of entries) {
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// #632: accept the key the baseline WRITER actually emits.
|
|
176
|
+
// tools/Update-ComplexityBaseline.ps1 (the tool this gate
|
|
177
|
+
// points at in its own fix-it message) writes "function",
|
|
178
|
+
// while this reader only knew "name" -- so every entry was
|
|
179
|
+
// dropped, "known" stayed empty, and every function above
|
|
180
|
+
// the threshold became a brand-new function. The ratchet
|
|
181
|
+
// could never pass. Both spellings are read: this is a
|
|
182
|
+
// reader, and the writer vocabulary is not ours to
|
|
183
|
+
// redefine from here.
|
|
184
|
+
if (!e || !e.file) continue;
|
|
185
|
+
const fnName = e.name || e.function;
|
|
186
|
+
if (!fnName) continue;
|
|
187
|
+
known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
190
|
// The measurement emits ABSOLUTE paths (pwsh resolved them), while
|
|
@@ -529,13 +540,43 @@ else
|
|
|
529
540
|
# this, a crashing or mis-invoked lizard yields 0 warnings and the gate
|
|
530
541
|
# reports PASSED on zero analysed functions -- the same silent-success
|
|
531
542
|
# failure this issue is about.
|
|
543
|
+
#
|
|
544
|
+
# #666: "no rows" has TWO causes and they must not share a verdict. A
|
|
545
|
+
# module of pure constants legitimately has zero functions, and lizard
|
|
546
|
+
# emits no row for it at all (measured: `lizard pure_data.py --csv` prints
|
|
547
|
+
# nothing and exits 0). That was reported as `lizard_output_unparsable`
|
|
548
|
+
# /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
|
|
549
|
+
# data-only diff was indistinguishable from a tooling fault. The two are
|
|
550
|
+
# separated by a POSITIVE probe: ask lizard to describe a fixture known to
|
|
551
|
+
# contain a function. If it produces rows, lizard works and the input
|
|
552
|
+
# simply had no functions (SKIP, `no_functions_in_input`); if it does not,
|
|
553
|
+
# the tool really is broken (WARN, `lizard_output_unparsable`).
|
|
532
554
|
if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
|
|
533
555
|
END { exit(found ? 0 : 1) }'; then
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
556
|
+
_lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
|
|
557
|
+
_lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
|
|
558
|
+
printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
|
|
559
|
+
_lizard_probe_rows=0
|
|
560
|
+
if [ -f "$_lizard_probe_file" ]; then
|
|
561
|
+
_lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
|
|
562
|
+
| lizard_batches --csv 2>/dev/null \
|
|
563
|
+
| awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
|
|
564
|
+
fi
|
|
565
|
+
rm -rf "$_lizard_probe_dir" 2>/dev/null || true
|
|
566
|
+
if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
|
|
567
|
+
echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
|
|
568
|
+
echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
|
|
569
|
+
GATE_3_STATUS="SKIP"
|
|
570
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
571
|
+
# shellcheck disable=SC2034
|
|
572
|
+
GATE_3_REASON="no_functions_in_input"
|
|
573
|
+
else
|
|
574
|
+
echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
|
|
575
|
+
GATE_3_STATUS="WARN"
|
|
576
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
577
|
+
# shellcheck disable=SC2034
|
|
578
|
+
GATE_3_REASON="lizard_output_unparsable"
|
|
579
|
+
fi
|
|
539
580
|
elif [ "$CC_WARNINGS" -gt 0 ]; then
|
|
540
581
|
echo "$CC_OUTPUT"
|
|
541
582
|
echo ""
|
package/gate-8.sh
CHANGED
|
@@ -59,10 +59,24 @@ fi
|
|
|
59
59
|
# revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
|
|
60
60
|
# secret was never seen and Gate 8 always passed. `git --staged` scans
|
|
61
61
|
# `git diff --cached`, which is exactly the pre-commit payload (#449).
|
|
62
|
+
#
|
|
63
|
+
# #616: `--all` is a different question, and the index is the wrong place to ask
|
|
64
|
+
# it. Under `xp-gate check . --all` the user asked "is the whole tree clean", the
|
|
65
|
+
# index is usually empty, `git --staged` legitimately found nothing, and that
|
|
66
|
+
# nothing was counted as PASSED -- a secret sitting in a tracked file was
|
|
67
|
+
# invisible to the one command meant to find it. The runner already exports
|
|
68
|
+
# GATE_SCOPE (#589); this fragment just has to read it. `dir --no-git` scans the
|
|
69
|
+
# filesystem so tracked-but-unmodified files are covered.
|
|
62
70
|
GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
|
|
63
71
|
rm -f "$GITLEAKS_REPORT"
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
73
|
+
echo " Scope: --all (scanning the working tree, not the index)"
|
|
74
|
+
GITLEAKS_OUTPUT=$($GITLEAKS_CMD dir --no-git --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
|
|
75
|
+
GITLEAKS_EXIT=$?
|
|
76
|
+
else
|
|
77
|
+
GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
|
|
78
|
+
GITLEAKS_EXIT=$?
|
|
79
|
+
fi
|
|
66
80
|
|
|
67
81
|
if [ "$GITLEAKS_EXIT" -eq 0 ]; then
|
|
68
82
|
# Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
|
|
@@ -183,7 +197,22 @@ fi
|
|
|
183
197
|
# if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
|
|
184
198
|
if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
|
|
185
199
|
DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
|
|
186
|
-
|
|
200
|
+
# #616: same scope split as the gitleaks call above. Under `--all` the index is
|
|
201
|
+
# usually empty, so piping `git diff --cached -z` into xargs fed the hook zero
|
|
202
|
+
# files and it reported success without reading anything.
|
|
203
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
204
|
+
if git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
|
|
205
|
+
echo " ✅ detect-secrets PASSED - no new secrets beyond baseline (scope: --all)."
|
|
206
|
+
else
|
|
207
|
+
cat "$DS_TMP"
|
|
208
|
+
echo ""
|
|
209
|
+
echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
|
|
210
|
+
echo " If these are false positives, regenerate the baseline (cross-platform):"
|
|
211
|
+
echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
|
|
212
|
+
rm -f "$DS_TMP"
|
|
213
|
+
exit 1
|
|
214
|
+
fi
|
|
215
|
+
elif git diff --cached --name-only --diff-filter=ACM -z \
|
|
187
216
|
| xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
|
|
188
217
|
echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
|
|
189
218
|
else
|
package/gate-9.sh
CHANGED
|
@@ -32,8 +32,20 @@ if [ -z "$SEMGREP_CMD" ]; then
|
|
|
32
32
|
echo " Gate 9: SAST Security (WARN, semgrep not installed)"
|
|
33
33
|
GATE_9_STATUS="WARN"
|
|
34
34
|
else
|
|
35
|
-
# Get staged files filtered to Semgrep-supported languages
|
|
36
|
-
|
|
35
|
+
# Get staged files filtered to Semgrep-supported languages.
|
|
36
|
+
#
|
|
37
|
+
# #616: under `--all` the index is usually empty, so the staged diff yielded no
|
|
38
|
+
# files and the gate reported SKIPPED -- "no supported language files changed"
|
|
39
|
+
# -- for a run whose whole purpose was to scan the tree. Same fail-open shape
|
|
40
|
+
# #589 fixed in Gate 3, one layer down: the CLI says the tree was checked and
|
|
41
|
+
# the fragment checks a diff that does not exist. The runner exports GATE_SCOPE;
|
|
42
|
+
# read it and enumerate tracked files instead.
|
|
43
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
44
|
+
echo " Scope: --all (scanning tracked files, not the index)"
|
|
45
|
+
SEMGREP_FILES=$(git ls-files 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
|
|
46
|
+
else
|
|
47
|
+
SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
|
|
48
|
+
fi
|
|
37
49
|
|
|
38
50
|
if [ -z "$SEMGREP_FILES" ]; then
|
|
39
51
|
echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-execution and tool-detection primitives shared by the gates.
|
|
3
|
+
* Split out of common.ts (Boy Scout: many-exports); re-exported there so
|
|
4
|
+
* every existing `from '../common'` import keeps working unchanged.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync, SpawnSyncReturns } from 'child_process';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
export interface ToolAvailability {
|
|
12
|
+
available: boolean;
|
|
13
|
+
path: string;
|
|
14
|
+
via: 'path' | 'npx' | 'custom' | 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToolResult {
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
timedOut: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PowerShellInfo {
|
|
25
|
+
available: boolean;
|
|
26
|
+
exe: string;
|
|
27
|
+
version: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Tool Detection ──
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cross-platform tool detection with 3-tier fallback:
|
|
34
|
+
* 1. PATH lookup (command -v equivalent)
|
|
35
|
+
* 2. npx fallback (for npm-installed tools)
|
|
36
|
+
* 3. Custom path (~/.local/bin/ on Unix)
|
|
37
|
+
*/
|
|
38
|
+
/** Tier 1: PATH lookup. */
|
|
39
|
+
function probeToolPath(tool: string): ToolAvailability {
|
|
40
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
41
|
+
try {
|
|
42
|
+
const pathCheck = spawnSync(whichCmd, [tool], { encoding: 'utf-8', timeout: 5000 });
|
|
43
|
+
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
|
|
44
|
+
return { available: true, path: pathCheck.stdout.trim().split('\n')[0], via: 'path' };
|
|
45
|
+
}
|
|
46
|
+
} catch { /* probe failed → not on PATH */ }
|
|
47
|
+
return { available: false, path: '', via: 'none' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tier 2: npx fallback (check if tool is available via npx). */
|
|
51
|
+
function probeToolNpx(tool: string, cwd?: string): ToolAvailability {
|
|
52
|
+
try {
|
|
53
|
+
const npxCheck = spawnSync('npx', ['--yes', tool, '--version'], {
|
|
54
|
+
encoding: 'utf-8',
|
|
55
|
+
timeout: 15000,
|
|
56
|
+
shell: process.platform === 'win32',
|
|
57
|
+
cwd,
|
|
58
|
+
});
|
|
59
|
+
if (npxCheck.status === 0) {
|
|
60
|
+
return { available: true, path: `npx ${tool}`, via: 'npx' };
|
|
61
|
+
}
|
|
62
|
+
} catch { /* probe failed → not reachable via npx */ }
|
|
63
|
+
return { available: false, path: '', via: 'none' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isToolAvailable(tool: string, cwd?: string): ToolAvailability {
|
|
67
|
+
// Tier 1: PATH lookup
|
|
68
|
+
const pathProbe = probeToolPath(tool);
|
|
69
|
+
if (pathProbe.available) return pathProbe;
|
|
70
|
+
|
|
71
|
+
// Tier 2: npx fallback
|
|
72
|
+
const npxProbe = probeToolNpx(tool, cwd);
|
|
73
|
+
if (npxProbe.available) return npxProbe;
|
|
74
|
+
|
|
75
|
+
// Tier 3: Custom path (~/.local/bin/)
|
|
76
|
+
if (process.platform !== 'win32') {
|
|
77
|
+
const customPath = path.join(os.homedir(), '.local', 'bin', tool);
|
|
78
|
+
if (fs.existsSync(customPath)) {
|
|
79
|
+
return { available: true, path: customPath, via: 'custom' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { available: false, path: '', via: 'none' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Process Execution ──
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cross-platform spawnSync wrapper with timeout and error handling.
|
|
90
|
+
* Uses spawnSync (not execSync) to avoid shell injection.
|
|
91
|
+
*/
|
|
92
|
+
export function runTool(
|
|
93
|
+
cmd: string,
|
|
94
|
+
args: string[],
|
|
95
|
+
opts?: { cwd?: string; env?: Record<string, string>; timeoutMs?: number; shell?: boolean }
|
|
96
|
+
): ToolResult {
|
|
97
|
+
try {
|
|
98
|
+
const result: SpawnSyncReturns<string> = spawnSync(cmd, args, {
|
|
99
|
+
encoding: 'utf-8',
|
|
100
|
+
cwd: opts?.cwd || process.cwd(),
|
|
101
|
+
env: { ...process.env, ...opts?.env },
|
|
102
|
+
timeout: opts?.timeoutMs || 120_000,
|
|
103
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
104
|
+
shell: opts?.shell || process.platform === 'win32',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
stdout: result.stdout || '',
|
|
109
|
+
stderr: result.stderr || '',
|
|
110
|
+
exitCode: result.status ?? -1,
|
|
111
|
+
timedOut: result.signal === 'SIGTERM' || (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT',
|
|
112
|
+
};
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// spawnSync throws on ENOENT-class failures; report them as a failed run
|
|
115
|
+
// with the message on stderr instead of crashing every caller.
|
|
116
|
+
return { stdout: '', stderr: err instanceof Error ? err.message : String(err), exitCode: -1, timedOut: false };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Git Helpers ──
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get list of changed (staged) files via git diff --cached.
|
|
124
|
+
* Equivalent to adapter-common.sh's git diff --cached --name-only.
|
|
125
|
+
*/
|
|
126
|
+
export function getChangedFiles(cwd?: string): string[] {
|
|
127
|
+
try {
|
|
128
|
+
const result = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM'], {
|
|
129
|
+
encoding: 'utf-8',
|
|
130
|
+
cwd: cwd || process.cwd(),
|
|
131
|
+
timeout: 10000,
|
|
132
|
+
});
|
|
133
|
+
if (result.status !== 0) return [];
|
|
134
|
+
return result.stdout
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map(f => f.trim())
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
} catch {
|
|
139
|
+
// No git / unreadable repo → no changed files, same as a failed status.
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── PowerShell Support ──
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Detect PowerShell executable (prefer pwsh 7+, fallback to powershell.exe 5.1).
|
|
148
|
+
* Mirrors powershell.sh's _detect_pwsh() pattern.
|
|
149
|
+
*/
|
|
150
|
+
/** Probe one candidate executable; null when it is not on PATH. */
|
|
151
|
+
function probePowerShellCandidate(exe: string): PowerShellInfo | null {
|
|
152
|
+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
153
|
+
try {
|
|
154
|
+
const check = spawnSync(whichCmd, [exe], { encoding: 'utf-8', timeout: 5000 });
|
|
155
|
+
if (check.status !== 0 || !check.stdout.trim()) return null;
|
|
156
|
+
const exePath = check.stdout.trim().split('\n')[0];
|
|
157
|
+
// Get version
|
|
158
|
+
const verResult = spawnSync(exe, ['-Command', '$PSVersionTable.PSVersion.ToString()'], {
|
|
159
|
+
encoding: 'utf-8',
|
|
160
|
+
timeout: 10000,
|
|
161
|
+
shell: process.platform === 'win32',
|
|
162
|
+
});
|
|
163
|
+
const version = verResult.status === 0 ? verResult.stdout.trim() : 'unknown';
|
|
164
|
+
return { available: true, exe: exePath, version };
|
|
165
|
+
} catch { /* probe failed → this candidate is not usable */ }
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function detectPowerShell(): PowerShellInfo {
|
|
170
|
+
const candidates = ['pwsh', 'powershell.exe', 'powershell'];
|
|
171
|
+
|
|
172
|
+
for (const exe of candidates) {
|
|
173
|
+
const found = probePowerShellCandidate(exe);
|
|
174
|
+
if (found) return found;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { available: false, exe: '', version: '' };
|
|
178
|
+
}
|
package/gates/common.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared infrastructure for TypeScript gate modules.
|
|
3
|
+
* Provides cross-platform tool detection, process execution, audit logging,
|
|
4
|
+
* project language detection, and PowerShell project support.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
|
|
10
|
+
// ── Types ──
|
|
11
|
+
|
|
12
|
+
export interface GateInput {
|
|
13
|
+
changedFiles: string[];
|
|
14
|
+
projectLang: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GateOutput {
|
|
19
|
+
status: 'PASS' | 'FAIL' | 'SKIP' | 'WARN';
|
|
20
|
+
messages: string[];
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Process & tool primitives (split to common-process.ts for module size) ──
|
|
25
|
+
|
|
26
|
+
import { runTool, type PowerShellInfo, type ToolResult } from './common-process';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
type ToolAvailability,
|
|
30
|
+
type ToolResult,
|
|
31
|
+
type PowerShellInfo,
|
|
32
|
+
isToolAvailable,
|
|
33
|
+
runTool,
|
|
34
|
+
getChangedFiles,
|
|
35
|
+
detectPowerShell,
|
|
36
|
+
} from './common-process';
|
|
37
|
+
|
|
38
|
+
// ── Audit Logging ──
|
|
39
|
+
|
|
40
|
+
const AUDIT_DIR = '.xp-gate';
|
|
41
|
+
const AUDIT_FILE = 'audit.jsonl';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Record gate execution to .xp-gate/audit.jsonl.
|
|
45
|
+
* Schema must match bash version's record_gate_audit() output.
|
|
46
|
+
*/
|
|
47
|
+
export function recordAudit(
|
|
48
|
+
gateId: string,
|
|
49
|
+
gateName: string,
|
|
50
|
+
status: string,
|
|
51
|
+
details: string | number,
|
|
52
|
+
startMs: number
|
|
53
|
+
): void {
|
|
54
|
+
const durationMs = Date.now() - startMs;
|
|
55
|
+
const entry = {
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
gate: gateId,
|
|
58
|
+
name: gateName,
|
|
59
|
+
status,
|
|
60
|
+
details: String(details),
|
|
61
|
+
duration_ms: durationMs,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (!fs.existsSync(AUDIT_DIR)) {
|
|
66
|
+
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
fs.appendFileSync(path.join(AUDIT_DIR, AUDIT_FILE), JSON.stringify(entry) + '\n');
|
|
69
|
+
} catch {
|
|
70
|
+
// Audit logging failure should not block the gate
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Language Detection ──
|
|
75
|
+
|
|
76
|
+
/** Any file in `dir` (non-recursive) matching one of the extensions. */
|
|
77
|
+
function hasFileWithExtension(dir: string, extensions: string[]): boolean {
|
|
78
|
+
try {
|
|
79
|
+
const files = fs.readdirSync(dir);
|
|
80
|
+
return files.some((f) => extensions.some((ext) => f.endsWith(ext)));
|
|
81
|
+
} catch { /* unreadable dir counts as absent — same as before */ }
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Java/Kotlin from Gradle markers: any .kt anywhere tips it to Kotlin. */
|
|
86
|
+
function detectJvmLanguage(dir: string): string {
|
|
87
|
+
try {
|
|
88
|
+
const files = fs.readdirSync(dir, { recursive: true }) as string[];
|
|
89
|
+
if (files.some(f => typeof f === 'string' && f.endsWith('.kt'))) return 'kotlin';
|
|
90
|
+
} catch { /* ignore */ }
|
|
91
|
+
return 'java';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Marker-file language probes, tried in order; first hit wins. */
|
|
95
|
+
const LANG_MARKERS: Array<{ lang: string; files: string[] }> = [
|
|
96
|
+
{ lang: 'typescript', files: ['tsconfig.json'] },
|
|
97
|
+
{ lang: 'python', files: ['pyproject.toml', 'requirements.txt', 'setup.py'] },
|
|
98
|
+
{ lang: 'go', files: ['go.mod'] },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/** Dart vs Flutter, or null when there is no pubspec.yaml. */
|
|
102
|
+
function detectDartFlutter(dir: string): string | null {
|
|
103
|
+
const pubspec = path.join(dir, 'pubspec.yaml');
|
|
104
|
+
if (!fs.existsSync(pubspec)) return null;
|
|
105
|
+
try {
|
|
106
|
+
const content = fs.readFileSync(pubspec, 'utf-8');
|
|
107
|
+
if (content.includes('flutter:') || fs.existsSync(path.join(dir, '.metadata'))) return 'flutter';
|
|
108
|
+
} catch { /* A pubspec we cannot read is still a Dart project. */ }
|
|
109
|
+
return 'dart';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** JVM build files: Gradle wins over Maven, matching the original order. */
|
|
113
|
+
function detectJvm(dir: string): string | null {
|
|
114
|
+
const hasGradle = fs.existsSync(path.join(dir, 'build.gradle'))
|
|
115
|
+
|| fs.existsSync(path.join(dir, 'build.gradle.kts'));
|
|
116
|
+
if (hasGradle) return detectJvmLanguage(dir);
|
|
117
|
+
if (fs.existsSync(path.join(dir, 'pom.xml'))) return 'java';
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Infrastructure-as-code by file extension, or null. */
|
|
122
|
+
function detectIac(dir: string): string | null {
|
|
123
|
+
try {
|
|
124
|
+
const files = fs.readdirSync(dir);
|
|
125
|
+
if (files.some((f) => f.endsWith('.tf') || f === 'Dockerfile')) return 'iac';
|
|
126
|
+
} catch { /* An unreadable directory proves nothing. */ }
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function detectProjectLang(cwd?: string): string {
|
|
131
|
+
const dir = cwd || process.cwd();
|
|
132
|
+
|
|
133
|
+
for (const marker of LANG_MARKERS) {
|
|
134
|
+
if (marker.files.some((f) => fs.existsSync(path.join(dir, f)))) return marker.lang;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const candidates = [
|
|
138
|
+
detectJvm(dir),
|
|
139
|
+
detectDartFlutter(dir),
|
|
140
|
+
hasFileWithExtension(dir, ['.psd1', '.psm1', '.ps1']) ? 'powershell' : null,
|
|
141
|
+
hasFileWithExtension(dir, ['.sh', '.bash']) ? 'shell' : null,
|
|
142
|
+
detectIac(dir),
|
|
143
|
+
];
|
|
144
|
+
return candidates.find((lang) => lang !== null) ?? 'unknown';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── PowerShell Support ──
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Detect PowerShell executable (prefer pwsh 7+, fallback to powershell.exe 5.1).
|
|
151
|
+
/**
|
|
152
|
+
* Check if the project language is PowerShell.
|
|
153
|
+
*/
|
|
154
|
+
export function isPowerShellProject(projectLang: string): boolean {
|
|
155
|
+
return projectLang === 'powershell';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run a PowerShell tool/script via the detected PowerShell executable.
|
|
160
|
+
*/
|
|
161
|
+
export function runPowerShellTool(
|
|
162
|
+
psInfo: PowerShellInfo,
|
|
163
|
+
command: string,
|
|
164
|
+
opts?: { cwd?: string; timeoutMs?: number }
|
|
165
|
+
): ToolResult {
|
|
166
|
+
if (!psInfo.available) {
|
|
167
|
+
return { stdout: '', stderr: 'PowerShell not available', exitCode: -1, timedOut: false };
|
|
168
|
+
}
|
|
169
|
+
return runTool(psInfo.exe, ['-NoProfile', '-NonInteractive', '-Command', command], {
|
|
170
|
+
cwd: opts?.cwd,
|
|
171
|
+
timeoutMs: opts?.timeoutMs,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Temp Directory ──
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Cross-platform temp directory (Windows: %TEMP%, Linux/macOS: /tmp).
|
|
179
|
+
*/
|
|
180
|
+
export function getTempDir(): string {
|
|
181
|
+
return os.tmpdir();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── File Filtering Helpers ──
|
|
185
|
+
|
|
186
|
+
/** Source file extensions that lizard can analyze. */
|
|
187
|
+
const LIZARD_EXTENSIONS = /\.(ts|tsx|js|jsx|py|go|java|swift|cpp|c|hpp|h|m|mm|kt)$/;
|
|
188
|
+
|
|
189
|
+
/** File extensions that semgrep supports. */
|
|
190
|
+
const SEMGREP_EXTENSIONS = /\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$/;
|
|
191
|
+
|
|
192
|
+
/** IaC file patterns. */
|
|
193
|
+
const IAC_PATTERN = /\.(tf|yaml|yml)$|Dockerfile/;
|
|
194
|
+
|
|
195
|
+
/** PowerShell DSC file patterns. */
|
|
196
|
+
const PS_DSC_PATTERN = /\.configuration\.ps1$|\.mof$/;
|
|
197
|
+
|
|
198
|
+
export function filterSourceFiles(files: string[]): string[] {
|
|
199
|
+
return files.filter(f => LIZARD_EXTENSIONS.test(f));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function filterSemgrepFiles(files: string[]): string[] {
|
|
203
|
+
return files.filter(f => SEMGREP_EXTENSIONS.test(f));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function filterIaCFiles(files: string[]): string[] {
|
|
207
|
+
return files.filter(f => IAC_PATTERN.test(f) || PS_DSC_PATTERN.test(f));
|
|
208
|
+
}
|
package/gates/gate-3.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate 3: Cyclomatic Complexity Check
|
|
3
|
+
* Uses lizard for source files; PSScriptAnalyzer for PowerShell projects.
|
|
4
|
+
* TypeScript rewrite of githooks/adapters/gate-3.sh for cross-platform Node.js CLI.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
GateInput, GateOutput, isToolAvailable, runTool, recordAudit,
|
|
8
|
+
filterSourceFiles, detectPowerShell, isPowerShellProject, runPowerShellTool,
|
|
9
|
+
getChangedFiles, detectProjectLang,
|
|
10
|
+
} from './common';
|
|
11
|
+
|
|
12
|
+
const CCN_THRESHOLD = 5;
|
|
13
|
+
|
|
14
|
+
export function runGate3(input: GateInput): GateOutput {
|
|
15
|
+
const startMs = Date.now();
|
|
16
|
+
const messages: string[] = [];
|
|
17
|
+
|
|
18
|
+
messages.push('');
|
|
19
|
+
messages.push('→ Gate 3: Cyclomatic complexity...');
|
|
20
|
+
|
|
21
|
+
// Documentation-only projects: skip
|
|
22
|
+
if (input.projectLang === 'documentation-only') {
|
|
23
|
+
messages.push('⏭️ SKIPPED - Complexity (documentation project).');
|
|
24
|
+
recordAudit('gate-3', 'complexity', 'SKIP', 0, startMs);
|
|
25
|
+
return { status: 'SKIP', messages, exitCode: 0 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// PowerShell project: use PSScriptAnalyzer as alternative
|
|
29
|
+
if (isPowerShellProject(input.projectLang)) {
|
|
30
|
+
return runPowerShellComplexity(input, startMs, messages);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Standard path: lizard
|
|
34
|
+
const tool = isToolAvailable('lizard', input.cwd);
|
|
35
|
+
if (!tool.available) {
|
|
36
|
+
messages.push('⚠️ WARN - lizard not installed, complexity check not performed');
|
|
37
|
+
messages.push(' Install with: pip install --user lizard');
|
|
38
|
+
messages.push(' Gate 3: Complexity check (WARN, tool not available)');
|
|
39
|
+
recordAudit('gate-3', 'complexity', 'WARN', 0, startMs);
|
|
40
|
+
return { status: 'WARN', messages, exitCode: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lizardCmd = tool.via === 'npx' ? 'npx' : tool.path;
|
|
44
|
+
const lizardArgs = tool.via === 'npx' ? ['-y', 'lizard'] : [];
|
|
45
|
+
|
|
46
|
+
// Filter to source files lizard can analyze
|
|
47
|
+
const ccFiles = filterSourceFiles(input.changedFiles);
|
|
48
|
+
if (ccFiles.length === 0) {
|
|
49
|
+
messages.push('⏭️ SKIPPED - Complexity (no source files to check).');
|
|
50
|
+
recordAudit('gate-3', 'complexity', 'SKIP', 0, startMs);
|
|
51
|
+
return { status: 'SKIP', messages, exitCode: 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
messages.push('Checking complexity for source files...');
|
|
55
|
+
|
|
56
|
+
// Run lizard with CCN threshold
|
|
57
|
+
const result = runTool(lizardCmd, [...lizardArgs, '-C', String(CCN_THRESHOLD), ...ccFiles], {
|
|
58
|
+
cwd: input.cwd,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const combinedOutput = result.stdout + result.stderr;
|
|
62
|
+
|
|
63
|
+
// Parse warning count from summary table: "Warning cnt 8"
|
|
64
|
+
const warningMatch = combinedOutput.match(/^Warning cnt\s+(\d+)/m);
|
|
65
|
+
const warnings = warningMatch ? parseInt(warningMatch[1], 10) : 0;
|
|
66
|
+
|
|
67
|
+
if (warnings > 0) {
|
|
68
|
+
messages.push(combinedOutput);
|
|
69
|
+
messages.push('');
|
|
70
|
+
messages.push(`❌ BLOCKED - ${warnings} functions with CCN > ${CCN_THRESHOLD} found.`);
|
|
71
|
+
messages.push(`Refactor high-complexity functions to keep below ${CCN_THRESHOLD} complexity.`);
|
|
72
|
+
recordAudit('gate-3', 'complexity', 'FAIL', warnings, startMs);
|
|
73
|
+
return { status: 'FAIL', messages, exitCode: 1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
messages.push(`✅ PASSED - All functions within complexity threshold (${CCN_THRESHOLD}).`);
|
|
77
|
+
recordAudit('gate-3', 'complexity', 'PASS', 0, startMs);
|
|
78
|
+
return { status: 'PASS', messages, exitCode: 0 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── CLI Entry Point ──
|
|
82
|
+
if (process.argv.includes('--run')) {
|
|
83
|
+
const cwdIdx = process.argv.indexOf('--cwd');
|
|
84
|
+
const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
|
|
85
|
+
const changedFiles = getChangedFiles(cwd);
|
|
86
|
+
const projectLang = detectProjectLang(cwd);
|
|
87
|
+
const result = runGate3({ changedFiles, projectLang, cwd });
|
|
88
|
+
result.messages.forEach(m => console.log(m));
|
|
89
|
+
process.exit(result.exitCode);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* PowerShell project complexity check using PSScriptAnalyzer.
|
|
94
|
+
* Uses PSAvoid* rules as approximate complexity indicators.
|
|
95
|
+
*/
|
|
96
|
+
function runPowerShellComplexity(
|
|
97
|
+
input: GateInput,
|
|
98
|
+
startMs: number,
|
|
99
|
+
messages: string[]
|
|
100
|
+
): GateOutput {
|
|
101
|
+
const ps = detectPowerShell();
|
|
102
|
+
if (!ps.available) {
|
|
103
|
+
messages.push('ℹ️ No PowerShell Clean Code / SOLID tool available');
|
|
104
|
+
messages.push('⏭️ SKIPPED - Complexity (no PowerShell tool)');
|
|
105
|
+
recordAudit('gate-3', 'complexity', 'SKIP', 0, startMs);
|
|
106
|
+
return { status: 'SKIP', messages, exitCode: 0 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Filter to .ps1 files
|
|
110
|
+
const psFiles = input.changedFiles.filter(f => f.endsWith('.ps1') || f.endsWith('.psm1'));
|
|
111
|
+
if (psFiles.length === 0) {
|
|
112
|
+
messages.push('⏭️ SKIPPED - Complexity (no PowerShell files to check).');
|
|
113
|
+
recordAudit('gate-3', 'complexity', 'SKIP', 0, startMs);
|
|
114
|
+
return { status: 'SKIP', messages, exitCode: 0 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
messages.push('Checking PowerShell code quality via PSScriptAnalyzer...');
|
|
118
|
+
|
|
119
|
+
const fileList = psFiles.map(f => `'${f}'`).join(',');
|
|
120
|
+
const command = `Invoke-ScriptAnalyzer -Path ${fileList} -IncludeRule PSAvoidUsingInvokeExpression,PSAvoidGlobalAliases -ErrorAction SilentlyContinue | ConvertTo-Json`;
|
|
121
|
+
const result = runPowerShellTool(ps, command, { cwd: input.cwd });
|
|
122
|
+
|
|
123
|
+
if (result.exitCode !== 0) {
|
|
124
|
+
messages.push('⚠️ PSScriptAnalyzer check failed, skipping');
|
|
125
|
+
recordAudit('gate-3', 'complexity', 'WARN', 0, startMs);
|
|
126
|
+
return { status: 'WARN', messages, exitCode: 0 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const findings = result.stdout.trim() ? JSON.parse(result.stdout.trim()) : [];
|
|
131
|
+
const count = Array.isArray(findings) ? findings.length : 0;
|
|
132
|
+
if (count > 0) {
|
|
133
|
+
messages.push(`⚠️ ${count} PSScriptAnalyzer findings (warnings only for PowerShell projects)`);
|
|
134
|
+
recordAudit('gate-3', 'complexity', 'WARN', count, startMs);
|
|
135
|
+
return { status: 'WARN', messages, exitCode: 0 };
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// JSON parse failure — not a gate failure
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
messages.push('✅ PASSED - PowerShell code quality check passed.');
|
|
142
|
+
recordAudit('gate-3', 'complexity', 'PASS', 0, startMs);
|
|
143
|
+
return { status: 'PASS', messages, exitCode: 0 };
|
|
144
|
+
}
|