@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.
Files changed (48) hide show
  1. package/gate-3.sh +48 -7
  2. package/gate-8.sh +32 -3
  3. package/gate-9.sh +14 -2
  4. package/gates/common-process.ts +178 -0
  5. package/gates/common.ts +208 -0
  6. package/gates/gate-3.ts +144 -0
  7. package/gates/gate-7.ts +147 -0
  8. package/gates/gate-8.ts +92 -0
  9. package/gates/gate-9.ts +269 -0
  10. package/gates/pbt-detect.ts +163 -0
  11. package/gates/python-health.ts +266 -0
  12. package/gates/test-layers.ts +312 -0
  13. package/hooks/gate-3.sh +48 -7
  14. package/hooks/gate-8.sh +32 -3
  15. package/hooks/gate-9.sh +14 -2
  16. package/hooks/pre-commit +51 -2
  17. package/lib/__tests__/check-normalize-exit.test.js +24 -0
  18. package/lib/__tests__/shared-utils.test.js +21 -0
  19. package/lib/__tests__/uninstall.test.js +18 -0
  20. package/lib/check.js +27 -4
  21. package/lib/gate-runner.js +6 -1
  22. package/lib/mattpocock-license-verdict.json +1 -1
  23. package/lib/shared-utils.js +6 -1
  24. package/lib/uninstall.js +21 -1
  25. package/mock-policy/AGENTS.md +2 -2
  26. package/mutation/AGENTS.md +2 -2
  27. package/package.json +2 -1
  28. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  32. package/plugins/dsh/package.json +1 -1
  33. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/opencode/package.json +1 -1
  37. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/qoder/plugin.json +1 -1
  41. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/scripts/sync-package-content.js +6 -1
  46. package/skills/delphi-review/AGENTS.md +2 -2
  47. package/skills/sprint-flow/AGENTS.md +2 -2
  48. 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
- if (!e || !e.file || !e.name) continue;
176
- known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
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
- 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"
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
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
65
- GITLEAKS_EXIT=$?
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
- if git diff --cached --name-only --diff-filter=ACM -z \
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
- 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)
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
+ }
@@ -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
+ }
@@ -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
+ }