@boyingliu01/xp-gate 0.32.3 → 0.33.1

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/bin/xp-gate.js +2 -2
  2. package/gates/common-process.ts +178 -0
  3. package/gates/common.ts +208 -0
  4. package/gates/gate-3.ts +144 -0
  5. package/gates/gate-7.ts +126 -0
  6. package/gates/gate-8.ts +92 -0
  7. package/gates/gate-9.ts +269 -0
  8. package/gates/pbt-detect.ts +163 -0
  9. package/gates/python-health.ts +266 -0
  10. package/gates/test-layers.ts +312 -0
  11. package/lib/__tests__/check-changed-brief.test.js +63 -0
  12. package/lib/__tests__/check-normalize-exit.test.js +24 -0
  13. package/lib/__tests__/shared-utils.test.js +21 -0
  14. package/lib/__tests__/uninstall.test.js +18 -0
  15. package/lib/check.js +140 -5
  16. package/lib/gate-runner.js +6 -1
  17. package/lib/mattpocock-license-verdict.json +1 -1
  18. package/lib/shared-utils.js +6 -1
  19. package/lib/uninstall.js +21 -1
  20. package/mock-policy/AGENTS.md +2 -2
  21. package/mutation/AGENTS.md +2 -2
  22. package/package.json +2 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  25. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +26 -0
  27. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  28. package/plugins/dsh/package.json +1 -1
  29. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +26 -0
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/plugins/opencode/package.json +1 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  35. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  36. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +26 -0
  37. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  38. package/plugins/qoder/plugin.json +1 -1
  39. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  40. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +26 -0
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  43. package/principles/AGENTS.md +2 -2
  44. package/scripts/sync-package-content.js +6 -1
  45. package/skills/delphi-review/AGENTS.md +2 -2
  46. package/skills/sprint-flow/AGENTS.md +2 -2
  47. package/skills/sprint-flow/references/phase-3-build.md +26 -0
  48. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -295,9 +295,9 @@ const COMMANDS = {
295
295
  usage: 'xp-gate baseline <create|show|reset|diff>'
296
296
  },
297
297
  'check': {
298
- description: 'Run quality gates (--list, --all, --gates <ids>, or <gate-id>)',
298
+ description: 'Run quality gates (--list, --all, --gates <ids>, --changed --brief, or <gate-id>)',
299
299
  run: subargs => check(subargs).then(code => process.exit(code)),
300
- usage: 'xp-gate check <file_or_directory|gate_number> [--gates gates] [--all] [--list]'
300
+ usage: 'xp-gate check <file_or_directory|gate_number> [--gates gates] [--all] [--changed --brief] [--list]'
301
301
  },
302
302
  'principles': {
303
303
  description: 'Run Clean Code + SOLID principles checker (Gate 4 standalone)',
@@ -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
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Gate 7: IaC Security Scanning (Terraform, Kubernetes, Docker, PowerShell DSC)
3
+ * Detects security issues in Infrastructure as Code files.
4
+ * TypeScript rewrite of githooks/adapters/gate-7.sh for cross-platform Node.js CLI.
5
+ */
6
+ import path from 'path';
7
+ import fs from 'fs';
8
+ import {
9
+ GateInput, GateOutput, isToolAvailable, runTool, recordAudit, filterIaCFiles,
10
+ getChangedFiles, detectProjectLang,
11
+ } from './common';
12
+
13
+ export function runGate7(input: GateInput): GateOutput {
14
+ const startMs = Date.now();
15
+ const cwd = input.cwd || process.cwd();
16
+ const messages: string[] = [];
17
+
18
+ messages.push('');
19
+ messages.push('→ Gate 7: IaC Security Scanning (Terraform, Kubernetes, Docker)...');
20
+
21
+ // Filter to IaC files (includes PowerShell DSC per design)
22
+ const iacFiles = filterIaCFiles(input.changedFiles);
23
+
24
+ if (iacFiles.length === 0) {
25
+ messages.push('✅ PASSED - No IaC files detected in changes.');
26
+ recordAudit('gate-7', 'iac-security', 'PASS', 0, startMs);
27
+ return { status: 'PASS', messages, exitCode: 0 };
28
+ }
29
+
30
+ // Check for IaC adapter script (bash path for pre-commit context)
31
+ const adapterPath = findIaCAdapter(cwd);
32
+ if (!adapterPath) {
33
+ messages.push('ℹ️ SKIP - IaC adapter not found');
34
+ messages.push('⏭️ SKIPPED - IaC security (no IaC adapter found)');
35
+ recordAudit('gate-7', 'iac-security', 'SKIP', 0, startMs);
36
+ return { status: 'SKIP', messages, exitCode: 0 };
37
+ }
38
+
39
+ // Run checkov if available (preferred tool)
40
+ const checkov = isToolAvailable('checkov', cwd);
41
+ if (checkov.available) {
42
+ return runCheckov(checkov.path, checkov.via, iacFiles, cwd, startMs, messages);
43
+ }
44
+
45
+ // Fallback: run the bash IaC adapter via bash
46
+ messages.push('⚠️ checkov not installed — falling back to bash IaC adapter');
47
+ messages.push(' Install: pip install checkov');
48
+
49
+ const result = runTool('bash', [adapterPath, ...iacFiles], { cwd });
50
+ const combinedOutput = result.stdout + result.stderr;
51
+
52
+ if (result.exitCode === 0) {
53
+ messages.push(combinedOutput);
54
+ messages.push('✅ PASSED - IaC security scan.');
55
+ recordAudit('gate-7', 'iac-security', 'PASS', 0, startMs);
56
+ return { status: 'PASS', messages, exitCode: 0 };
57
+ }
58
+
59
+ messages.push(combinedOutput);
60
+ messages.push('');
61
+ messages.push('❌ BLOCKED - IaC security issues detected');
62
+ messages.push('Fix the security issues above before committing.');
63
+ messages.push('Tip: Install checkov for comprehensive IaC scanning: pip install checkov');
64
+ recordAudit('gate-7', 'iac-security', 'FAIL', result.exitCode, startMs);
65
+ return { status: 'FAIL', messages, exitCode: 1 };
66
+ }
67
+
68
+ function runCheckov(
69
+ cmdPath: string,
70
+ via: string,
71
+ files: string[],
72
+ cwd: string,
73
+ startMs: number,
74
+ messages: string[]
75
+ ): GateOutput {
76
+ const checkovCmd = via === 'npx' ? 'npx' : cmdPath;
77
+ const checkovArgs = via === 'npx' ? ['-y', 'checkov'] : [];
78
+
79
+ // Detect config file
80
+ const configPath = path.join(cwd, '.checkov.yaml');
81
+ if (fs.existsSync(configPath)) {
82
+ checkovArgs.push('--config-file', configPath);
83
+ }
84
+
85
+ // Run checkov on the changed IaC files
86
+ checkovArgs.push('--file', ...files, '--quiet', '--compact');
87
+
88
+ const result = runTool(checkovCmd, checkovArgs, { cwd });
89
+ const combinedOutput = result.stdout + result.stderr;
90
+
91
+ if (result.exitCode === 0) {
92
+ messages.push(combinedOutput);
93
+ messages.push('✅ PASSED - IaC security scan.');
94
+ recordAudit('gate-7', 'iac-security', 'PASS', 0, startMs);
95
+ return { status: 'PASS', messages, exitCode: 0 };
96
+ }
97
+
98
+ messages.push(combinedOutput);
99
+ messages.push('');
100
+ messages.push('❌ BLOCKED - IaC security issues detected');
101
+ messages.push('Fix the security issues above before committing.');
102
+ recordAudit('gate-7', 'iac-security', 'FAIL', result.exitCode, startMs);
103
+ return { status: 'FAIL', messages, exitCode: 1 };
104
+ }
105
+
106
+ function findIaCAdapter(cwd: string): string | null {
107
+ const candidates = [
108
+ path.join(cwd, 'githooks', 'adapters', 'iac.sh'),
109
+ path.join(cwd, 'githooks', 'adapters', 'iac', 'iac.sh'),
110
+ ];
111
+ for (const c of candidates) {
112
+ if (fs.existsSync(c)) return c;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // ── CLI Entry Point ──
118
+ if (process.argv.includes('--run')) {
119
+ const cwdIdx = process.argv.indexOf('--cwd');
120
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
121
+ const changedFiles = getChangedFiles(cwd);
122
+ const projectLang = detectProjectLang(cwd);
123
+ const result = runGate7({ changedFiles, projectLang, cwd });
124
+ result.messages.forEach(m => console.log(m));
125
+ process.exit(result.exitCode);
126
+ }