@yuaone/core 0.1.0
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/LICENSE +663 -0
- package/README.md +15 -0
- package/dist/__tests__/context-manager.test.d.ts +6 -0
- package/dist/__tests__/context-manager.test.d.ts.map +1 -0
- package/dist/__tests__/context-manager.test.js +220 -0
- package/dist/__tests__/context-manager.test.js.map +1 -0
- package/dist/__tests__/governor.test.d.ts +6 -0
- package/dist/__tests__/governor.test.d.ts.map +1 -0
- package/dist/__tests__/governor.test.js +210 -0
- package/dist/__tests__/governor.test.js.map +1 -0
- package/dist/__tests__/model-router.test.d.ts +6 -0
- package/dist/__tests__/model-router.test.d.ts.map +1 -0
- package/dist/__tests__/model-router.test.js +329 -0
- package/dist/__tests__/model-router.test.js.map +1 -0
- package/dist/agent-logger.d.ts +384 -0
- package/dist/agent-logger.d.ts.map +1 -0
- package/dist/agent-logger.js +820 -0
- package/dist/agent-logger.js.map +1 -0
- package/dist/agent-loop.d.ts +163 -0
- package/dist/agent-loop.d.ts.map +1 -0
- package/dist/agent-loop.js +609 -0
- package/dist/agent-loop.js.map +1 -0
- package/dist/agent-modes.d.ts +85 -0
- package/dist/agent-modes.d.ts.map +1 -0
- package/dist/agent-modes.js +418 -0
- package/dist/agent-modes.js.map +1 -0
- package/dist/approval.d.ts +137 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +299 -0
- package/dist/approval.js.map +1 -0
- package/dist/async-completion-queue.d.ts +56 -0
- package/dist/async-completion-queue.d.ts.map +1 -0
- package/dist/async-completion-queue.js +77 -0
- package/dist/async-completion-queue.js.map +1 -0
- package/dist/auto-fix.d.ts +174 -0
- package/dist/auto-fix.d.ts.map +1 -0
- package/dist/auto-fix.js +319 -0
- package/dist/auto-fix.js.map +1 -0
- package/dist/codebase-context.d.ts +396 -0
- package/dist/codebase-context.d.ts.map +1 -0
- package/dist/codebase-context.js +1260 -0
- package/dist/codebase-context.js.map +1 -0
- package/dist/conflict-resolver.d.ts +191 -0
- package/dist/conflict-resolver.d.ts.map +1 -0
- package/dist/conflict-resolver.js +524 -0
- package/dist/conflict-resolver.js.map +1 -0
- package/dist/constants.d.ts +52 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +141 -0
- package/dist/constants.js.map +1 -0
- package/dist/context-budget.d.ts +435 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +903 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/context-compressor.d.ts +143 -0
- package/dist/context-compressor.d.ts.map +1 -0
- package/dist/context-compressor.js +511 -0
- package/dist/context-compressor.js.map +1 -0
- package/dist/context-manager.d.ts +112 -0
- package/dist/context-manager.d.ts.map +1 -0
- package/dist/context-manager.js +247 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/continuous-reflection.d.ts +267 -0
- package/dist/continuous-reflection.d.ts.map +1 -0
- package/dist/continuous-reflection.js +338 -0
- package/dist/continuous-reflection.js.map +1 -0
- package/dist/cross-file-refactor.d.ts +352 -0
- package/dist/cross-file-refactor.d.ts.map +1 -0
- package/dist/cross-file-refactor.js +1544 -0
- package/dist/cross-file-refactor.js.map +1 -0
- package/dist/dag-orchestrator.d.ts +138 -0
- package/dist/dag-orchestrator.d.ts.map +1 -0
- package/dist/dag-orchestrator.js +379 -0
- package/dist/dag-orchestrator.js.map +1 -0
- package/dist/debate-orchestrator.d.ts +301 -0
- package/dist/debate-orchestrator.d.ts.map +1 -0
- package/dist/debate-orchestrator.js +719 -0
- package/dist/debate-orchestrator.js.map +1 -0
- package/dist/dependency-analyzer.d.ts +113 -0
- package/dist/dependency-analyzer.d.ts.map +1 -0
- package/dist/dependency-analyzer.js +444 -0
- package/dist/dependency-analyzer.js.map +1 -0
- package/dist/design-loop.d.ts +59 -0
- package/dist/design-loop.d.ts.map +1 -0
- package/dist/design-loop.js +344 -0
- package/dist/design-loop.js.map +1 -0
- package/dist/doc-intelligence.d.ts +383 -0
- package/dist/doc-intelligence.d.ts.map +1 -0
- package/dist/doc-intelligence.js +1307 -0
- package/dist/doc-intelligence.js.map +1 -0
- package/dist/dynamic-role-generator.d.ts +76 -0
- package/dist/dynamic-role-generator.d.ts.map +1 -0
- package/dist/dynamic-role-generator.js +194 -0
- package/dist/dynamic-role-generator.js.map +1 -0
- package/dist/errors.d.ts +69 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +102 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-bus.d.ts +159 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +305 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/execution-engine.d.ts +425 -0
- package/dist/execution-engine.d.ts.map +1 -0
- package/dist/execution-engine.js +1555 -0
- package/dist/execution-engine.js.map +1 -0
- package/dist/git-intelligence.d.ts +306 -0
- package/dist/git-intelligence.d.ts.map +1 -0
- package/dist/git-intelligence.js +1099 -0
- package/dist/git-intelligence.js.map +1 -0
- package/dist/governor.d.ts +77 -0
- package/dist/governor.d.ts.map +1 -0
- package/dist/governor.js +161 -0
- package/dist/governor.js.map +1 -0
- package/dist/hierarchical-planner.d.ts +313 -0
- package/dist/hierarchical-planner.d.ts.map +1 -0
- package/dist/hierarchical-planner.js +981 -0
- package/dist/hierarchical-planner.js.map +1 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +123 -0
- package/dist/index.js.map +1 -0
- package/dist/intent-inference.d.ts +103 -0
- package/dist/intent-inference.d.ts.map +1 -0
- package/dist/intent-inference.js +605 -0
- package/dist/intent-inference.js.map +1 -0
- package/dist/interrupt-manager.d.ts +143 -0
- package/dist/interrupt-manager.d.ts.map +1 -0
- package/dist/interrupt-manager.js +196 -0
- package/dist/interrupt-manager.js.map +1 -0
- package/dist/kernel.d.ts +564 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1419 -0
- package/dist/kernel.js.map +1 -0
- package/dist/language-support.d.ts +232 -0
- package/dist/language-support.d.ts.map +1 -0
- package/dist/language-support.js +1134 -0
- package/dist/language-support.js.map +1 -0
- package/dist/llm-client.d.ts +82 -0
- package/dist/llm-client.d.ts.map +1 -0
- package/dist/llm-client.js +475 -0
- package/dist/llm-client.js.map +1 -0
- package/dist/mcp-client.d.ts +232 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +718 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/memory-manager.d.ts +200 -0
- package/dist/memory-manager.d.ts.map +1 -0
- package/dist/memory-manager.js +568 -0
- package/dist/memory-manager.js.map +1 -0
- package/dist/memory.d.ts +87 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +341 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-router.d.ts +245 -0
- package/dist/model-router.d.ts.map +1 -0
- package/dist/model-router.js +632 -0
- package/dist/model-router.js.map +1 -0
- package/dist/parallel-executor.d.ts +125 -0
- package/dist/parallel-executor.d.ts.map +1 -0
- package/dist/parallel-executor.js +201 -0
- package/dist/parallel-executor.js.map +1 -0
- package/dist/perf-optimizer.d.ts +212 -0
- package/dist/perf-optimizer.d.ts.map +1 -0
- package/dist/perf-optimizer.js +721 -0
- package/dist/perf-optimizer.js.map +1 -0
- package/dist/persona.d.ts +305 -0
- package/dist/persona.d.ts.map +1 -0
- package/dist/persona.js +887 -0
- package/dist/persona.js.map +1 -0
- package/dist/planner.d.ts +70 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +264 -0
- package/dist/planner.js.map +1 -0
- package/dist/qa-pipeline.d.ts +365 -0
- package/dist/qa-pipeline.d.ts.map +1 -0
- package/dist/qa-pipeline.js +1352 -0
- package/dist/qa-pipeline.js.map +1 -0
- package/dist/reasoning-adapter.d.ts +116 -0
- package/dist/reasoning-adapter.d.ts.map +1 -0
- package/dist/reasoning-adapter.js +187 -0
- package/dist/reasoning-adapter.js.map +1 -0
- package/dist/role-registry.d.ts +55 -0
- package/dist/role-registry.d.ts.map +1 -0
- package/dist/role-registry.js +192 -0
- package/dist/role-registry.js.map +1 -0
- package/dist/sandbox-tiers.d.ts +327 -0
- package/dist/sandbox-tiers.d.ts.map +1 -0
- package/dist/sandbox-tiers.js +928 -0
- package/dist/sandbox-tiers.js.map +1 -0
- package/dist/security-scanner.d.ts +222 -0
- package/dist/security-scanner.d.ts.map +1 -0
- package/dist/security-scanner.js +1129 -0
- package/dist/security-scanner.js.map +1 -0
- package/dist/security.d.ts +93 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +393 -0
- package/dist/security.js.map +1 -0
- package/dist/self-reflection.d.ts +397 -0
- package/dist/self-reflection.d.ts.map +1 -0
- package/dist/self-reflection.js +908 -0
- package/dist/self-reflection.js.map +1 -0
- package/dist/session-persistence.d.ts +191 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +395 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/speculative-executor.d.ts +210 -0
- package/dist/speculative-executor.d.ts.map +1 -0
- package/dist/speculative-executor.js +618 -0
- package/dist/speculative-executor.js.map +1 -0
- package/dist/state-machine.d.ts +289 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +695 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/sub-agent.d.ts +177 -0
- package/dist/sub-agent.d.ts.map +1 -0
- package/dist/sub-agent.js +303 -0
- package/dist/sub-agent.js.map +1 -0
- package/dist/system-prompt.d.ts +26 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/system-prompt.js +84 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/test-intelligence.d.ts +439 -0
- package/dist/test-intelligence.d.ts.map +1 -0
- package/dist/test-intelligence.js +1165 -0
- package/dist/test-intelligence.js.map +1 -0
- package/dist/types.d.ts +632 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/vector-index.d.ts +314 -0
- package/dist/vector-index.d.ts.map +1 -0
- package/dist/vector-index.js +618 -0
- package/dist/vector-index.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module qa-pipeline
|
|
3
|
+
* @description 5-Stage QA Agent Pipeline — 코드 변경 후 자동 품질 검증.
|
|
4
|
+
*
|
|
5
|
+
* Stages:
|
|
6
|
+
* 1. Structural — TypeScript 컴파일, ESLint, 순환 import, export 검증
|
|
7
|
+
* 2. Semantic — 유닛/통합 테스트 실행
|
|
8
|
+
* 3. Quality — 복잡도, 함수/파일 길이, TODO, 디버그 문, 보안 스캔
|
|
9
|
+
* 4. Review — LLM 기반 코드 리뷰 (thorough 모드 전용)
|
|
10
|
+
* 5. Decision — 전체 결과 기반 자동 판정 (approve / fix_and_retry / escalate)
|
|
11
|
+
*
|
|
12
|
+
* QALevel 프리셋:
|
|
13
|
+
* - quick: structural only
|
|
14
|
+
* - standard: structural + semantic + quality
|
|
15
|
+
* - thorough: all 5 stages
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { readFile, readdir, access, constants } from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
22
|
+
// Defaults
|
|
23
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
24
|
+
const DEFAULT_QUALITY_GATES = {
|
|
25
|
+
maxCyclomaticComplexity: 15,
|
|
26
|
+
maxFunctionLength: 50,
|
|
27
|
+
maxFileLength: 500,
|
|
28
|
+
minTestCoverage: 0,
|
|
29
|
+
maxTodoCount: -1,
|
|
30
|
+
noNewWarnings: true,
|
|
31
|
+
};
|
|
32
|
+
/** QALevel에 따른 stage toggle 프리셋 */
|
|
33
|
+
function applyLevelDefaults(level) {
|
|
34
|
+
switch (level) {
|
|
35
|
+
case "quick":
|
|
36
|
+
return {
|
|
37
|
+
enableStructural: true,
|
|
38
|
+
enableSemantic: false,
|
|
39
|
+
enableQuality: false,
|
|
40
|
+
enableReview: false,
|
|
41
|
+
enableDecision: true,
|
|
42
|
+
};
|
|
43
|
+
case "standard":
|
|
44
|
+
return {
|
|
45
|
+
enableStructural: true,
|
|
46
|
+
enableSemantic: true,
|
|
47
|
+
enableQuality: true,
|
|
48
|
+
enableReview: false,
|
|
49
|
+
enableDecision: true,
|
|
50
|
+
};
|
|
51
|
+
case "thorough":
|
|
52
|
+
return {
|
|
53
|
+
enableStructural: true,
|
|
54
|
+
enableSemantic: true,
|
|
55
|
+
enableQuality: true,
|
|
56
|
+
enableReview: true,
|
|
57
|
+
enableDecision: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function buildFullConfig(partial) {
|
|
62
|
+
const level = partial.level ?? "standard";
|
|
63
|
+
const levelDefaults = applyLevelDefaults(level);
|
|
64
|
+
return {
|
|
65
|
+
projectPath: partial.projectPath,
|
|
66
|
+
level,
|
|
67
|
+
enableStructural: partial.enableStructural ?? levelDefaults.enableStructural,
|
|
68
|
+
enableSemantic: partial.enableSemantic ?? levelDefaults.enableSemantic,
|
|
69
|
+
enableQuality: partial.enableQuality ?? levelDefaults.enableQuality,
|
|
70
|
+
enableReview: partial.enableReview ?? levelDefaults.enableReview,
|
|
71
|
+
enableDecision: partial.enableDecision ?? levelDefaults.enableDecision,
|
|
72
|
+
autoFix: partial.autoFix ?? true,
|
|
73
|
+
maxFixAttempts: partial.maxFixAttempts ?? 3,
|
|
74
|
+
fixableCategories: partial.fixableCategories ?? ["lint", "format", "imports", "types"],
|
|
75
|
+
qualityGates: { ...DEFAULT_QUALITY_GATES, ...partial.qualityGates },
|
|
76
|
+
buildTimeout: partial.buildTimeout ?? 60_000,
|
|
77
|
+
testTimeout: partial.testTimeout ?? 120_000,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Control-flow keywords that increase cyclomatic complexity
|
|
81
|
+
const COMPLEXITY_KEYWORDS = /\b(if|else\s+if|for|while|do|switch|case|catch)\b/g;
|
|
82
|
+
const LOGICAL_OPERATORS = /(\&\&|\|\||\?\?)/g;
|
|
83
|
+
const TERNARY_OPERATOR = /\?[^:?]*:/g;
|
|
84
|
+
// Function detection (named functions, methods, arrow functions assigned to const/let)
|
|
85
|
+
const FUNCTION_PATTERN = /(?:(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(|(\w+)\s*\([^)]*\)\s*\{|(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>)/g;
|
|
86
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
87
|
+
// Security Patterns
|
|
88
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
89
|
+
const SECURITY_PATTERNS = [
|
|
90
|
+
{ name: "Hardcoded password", pattern: /password\s*=\s*["'][^"']+["']/gi, severity: "critical" },
|
|
91
|
+
{ name: "Hardcoded API key", pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*["'][A-Za-z0-9+/=]{16,}["']/gi, severity: "critical" },
|
|
92
|
+
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/g, severity: "critical" },
|
|
93
|
+
{ name: "Private key", pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, severity: "critical" },
|
|
94
|
+
{ name: "eval() usage", pattern: /\beval\s*\(/g, severity: "high" },
|
|
95
|
+
{ name: "new Function() usage", pattern: /new\s+Function\s*\(/g, severity: "high" },
|
|
96
|
+
{ name: "SQL injection risk", pattern: /(?:query|execute)\s*\(\s*`[^`]*\$\{/g, severity: "high" },
|
|
97
|
+
{ name: "Path traversal", pattern: /\.\.\//g, severity: "medium" },
|
|
98
|
+
{ name: "innerHTML assignment", pattern: /\.innerHTML\s*=/g, severity: "medium" },
|
|
99
|
+
];
|
|
100
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
101
|
+
// QAPipeline
|
|
102
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
103
|
+
/**
|
|
104
|
+
* QAPipeline — 5단계 자동 품질 검증 파이프라인.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* const qa = new QAPipeline({ projectPath: "/project", level: "standard" });
|
|
109
|
+
*
|
|
110
|
+
* qa.on("stage:complete", (result) => console.log(result.stage, result.status));
|
|
111
|
+
*
|
|
112
|
+
* const result = await qa.run(["src/foo.ts", "src/bar.ts"]);
|
|
113
|
+
* if (result.decision.action === "approve") {
|
|
114
|
+
* console.log("All checks passed!");
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export class QAPipeline extends EventEmitter {
|
|
119
|
+
config;
|
|
120
|
+
constructor(config) {
|
|
121
|
+
super();
|
|
122
|
+
this.config = buildFullConfig(config);
|
|
123
|
+
}
|
|
124
|
+
// ─── Main ───────────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* 전체 파이프라인 실행.
|
|
127
|
+
*
|
|
128
|
+
* @param changedFiles 변경된 파일 경로 목록 (없으면 전체 검사)
|
|
129
|
+
* @param reviewFn LLM 리뷰 함수 (review stage 활성화 시 필요)
|
|
130
|
+
* @returns 파이프라인 전체 결과
|
|
131
|
+
*/
|
|
132
|
+
async run(changedFiles, reviewFn) {
|
|
133
|
+
const stages = [];
|
|
134
|
+
const startTime = Date.now();
|
|
135
|
+
const gateResults = [];
|
|
136
|
+
// Stage 1: Structural
|
|
137
|
+
if (this.config.enableStructural) {
|
|
138
|
+
this.emit("stage:start", "structural");
|
|
139
|
+
let result = await this.runStructural();
|
|
140
|
+
stages.push(result);
|
|
141
|
+
this.emit("stage:complete", result);
|
|
142
|
+
// Auto-fix structural issues
|
|
143
|
+
if (result.status === "fail" && this.config.autoFix) {
|
|
144
|
+
for (let i = 0; i < this.config.maxFixAttempts; i++) {
|
|
145
|
+
const fixableChecks = result.checks.filter((c) => c.status === "fail" && c.fixable);
|
|
146
|
+
if (fixableChecks.length === 0)
|
|
147
|
+
break;
|
|
148
|
+
for (const check of fixableChecks) {
|
|
149
|
+
const fix = await this.attemptFix(check, i + 1);
|
|
150
|
+
result.autoFixed.push(fix);
|
|
151
|
+
this.emit("fix:attempt", fix);
|
|
152
|
+
}
|
|
153
|
+
// Re-run structural after fix
|
|
154
|
+
const rerun = await this.runStructural();
|
|
155
|
+
result = { ...rerun, autoFixed: result.autoFixed };
|
|
156
|
+
stages[stages.length - 1] = result;
|
|
157
|
+
if (result.status !== "fail")
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Stage 2: Semantic (skip if structural fails critically)
|
|
163
|
+
if (this.config.enableSemantic && !this.hasCriticalFailure(stages)) {
|
|
164
|
+
this.emit("stage:start", "semantic");
|
|
165
|
+
const result = await this.runSemantic(changedFiles);
|
|
166
|
+
stages.push(result);
|
|
167
|
+
this.emit("stage:complete", result);
|
|
168
|
+
}
|
|
169
|
+
// Stage 3: Quality
|
|
170
|
+
if (this.config.enableQuality) {
|
|
171
|
+
this.emit("stage:start", "quality");
|
|
172
|
+
const result = await this.runQuality(changedFiles);
|
|
173
|
+
stages.push(result);
|
|
174
|
+
this.emit("stage:complete", result);
|
|
175
|
+
// Collect gate results from quality checks
|
|
176
|
+
gateResults.push(...this.collectGateResults(result));
|
|
177
|
+
}
|
|
178
|
+
// Stage 4: Review (thorough only, requires reviewFn)
|
|
179
|
+
if (this.config.enableReview && reviewFn && changedFiles?.length) {
|
|
180
|
+
this.emit("stage:start", "review");
|
|
181
|
+
const result = await this.runReview(changedFiles, reviewFn);
|
|
182
|
+
stages.push(result);
|
|
183
|
+
this.emit("stage:complete", result);
|
|
184
|
+
}
|
|
185
|
+
// Stage 5: Decision (always)
|
|
186
|
+
const decision = this.makeDecision(stages);
|
|
187
|
+
// Aggregate counts
|
|
188
|
+
let totalChecks = 0;
|
|
189
|
+
let passed = 0;
|
|
190
|
+
let warnings = 0;
|
|
191
|
+
let failures = 0;
|
|
192
|
+
let autoFixed = 0;
|
|
193
|
+
for (const s of stages) {
|
|
194
|
+
totalChecks += s.checks.length;
|
|
195
|
+
for (const c of s.checks) {
|
|
196
|
+
if (c.status === "pass")
|
|
197
|
+
passed++;
|
|
198
|
+
else if (c.status === "warn")
|
|
199
|
+
warnings++;
|
|
200
|
+
else
|
|
201
|
+
failures++;
|
|
202
|
+
}
|
|
203
|
+
autoFixed += s.autoFixed.filter((f) => f.success).length;
|
|
204
|
+
}
|
|
205
|
+
const overall = this.determineOverall(stages);
|
|
206
|
+
const totalDuration = Date.now() - startTime;
|
|
207
|
+
const pipelineResult = {
|
|
208
|
+
overall,
|
|
209
|
+
stages,
|
|
210
|
+
totalChecks,
|
|
211
|
+
passed,
|
|
212
|
+
warnings,
|
|
213
|
+
failures,
|
|
214
|
+
autoFixed,
|
|
215
|
+
totalDuration,
|
|
216
|
+
gateResults,
|
|
217
|
+
decision,
|
|
218
|
+
};
|
|
219
|
+
this.emit("pipeline:complete", pipelineResult);
|
|
220
|
+
return pipelineResult;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 특정 stage만 단독 실행.
|
|
224
|
+
*
|
|
225
|
+
* @param stage 실행할 stage
|
|
226
|
+
* @param changedFiles 변경된 파일 목록
|
|
227
|
+
* @param reviewFn LLM 리뷰 함수 (review stage 시 필요)
|
|
228
|
+
*/
|
|
229
|
+
async runStage(stage, changedFiles, reviewFn) {
|
|
230
|
+
switch (stage) {
|
|
231
|
+
case "structural":
|
|
232
|
+
return this.runStructural();
|
|
233
|
+
case "semantic":
|
|
234
|
+
return this.runSemantic(changedFiles);
|
|
235
|
+
case "quality":
|
|
236
|
+
return this.runQuality(changedFiles);
|
|
237
|
+
case "review":
|
|
238
|
+
if (!reviewFn || !changedFiles?.length) {
|
|
239
|
+
return this.buildStageResult("review", [], [], Date.now(), "skip");
|
|
240
|
+
}
|
|
241
|
+
return this.runReview(changedFiles, reviewFn);
|
|
242
|
+
case "decision":
|
|
243
|
+
return this.buildStageResult("decision", [], [], Date.now(), "skip");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ─── Stage 1: Structural Validation ─────────────────────────────────
|
|
247
|
+
/**
|
|
248
|
+
* Stage 1 — 구조 검증: TypeScript 컴파일, ESLint, 순환 import, export 검사.
|
|
249
|
+
*/
|
|
250
|
+
async runStructural() {
|
|
251
|
+
const startTime = Date.now();
|
|
252
|
+
const checks = [];
|
|
253
|
+
this.emit("check:run", "TypeScript Compilation");
|
|
254
|
+
const tsCheck = await this.checkTypeScript();
|
|
255
|
+
checks.push(tsCheck);
|
|
256
|
+
this.emit("check:result", tsCheck);
|
|
257
|
+
this.emit("check:run", "ESLint");
|
|
258
|
+
const lintCheck = await this.checkLint();
|
|
259
|
+
checks.push(lintCheck);
|
|
260
|
+
this.emit("check:result", lintCheck);
|
|
261
|
+
this.emit("check:run", "Circular Imports");
|
|
262
|
+
const circularCheck = await this.checkCircularImports();
|
|
263
|
+
checks.push(circularCheck);
|
|
264
|
+
this.emit("check:result", circularCheck);
|
|
265
|
+
this.emit("check:run", "Exports");
|
|
266
|
+
const exportCheck = await this.checkExports();
|
|
267
|
+
checks.push(exportCheck);
|
|
268
|
+
this.emit("check:result", exportCheck);
|
|
269
|
+
return this.buildStageResult("structural", checks, [], startTime);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* TypeScript 타입 검사 (tsc --noEmit).
|
|
273
|
+
*/
|
|
274
|
+
async checkTypeScript() {
|
|
275
|
+
const hasTsc = await this.fileExists(path.join(this.config.projectPath, "node_modules/.bin/tsc"));
|
|
276
|
+
if (!hasTsc) {
|
|
277
|
+
return {
|
|
278
|
+
name: "TypeScript Compilation",
|
|
279
|
+
status: "pass",
|
|
280
|
+
message: "tsc not found, skipping type check",
|
|
281
|
+
fixable: false,
|
|
282
|
+
severity: "info",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const result = await this.exec("npx", ["tsc", "--noEmit", "--pretty", "false"], this.config.buildTimeout);
|
|
286
|
+
if (result.exitCode === 0) {
|
|
287
|
+
return {
|
|
288
|
+
name: "TypeScript Compilation",
|
|
289
|
+
status: "pass",
|
|
290
|
+
message: "No type errors",
|
|
291
|
+
fixable: false,
|
|
292
|
+
severity: "info",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const errorLines = result.stderr
|
|
296
|
+
.split("\n")
|
|
297
|
+
.filter((l) => /error TS\d+/.test(l));
|
|
298
|
+
const errorCount = errorLines.length || 1;
|
|
299
|
+
return {
|
|
300
|
+
name: "TypeScript Compilation",
|
|
301
|
+
status: "fail",
|
|
302
|
+
message: `${errorCount} TypeScript error(s)`,
|
|
303
|
+
details: errorLines.slice(0, 20),
|
|
304
|
+
fixable: this.config.fixableCategories.includes("types"),
|
|
305
|
+
severity: "critical",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* ESLint 검사.
|
|
310
|
+
*/
|
|
311
|
+
async checkLint() {
|
|
312
|
+
const hasEslint = await this.fileExists(path.join(this.config.projectPath, "node_modules/.bin/eslint"));
|
|
313
|
+
if (!hasEslint) {
|
|
314
|
+
return {
|
|
315
|
+
name: "ESLint",
|
|
316
|
+
status: "pass",
|
|
317
|
+
message: "ESLint not installed, skipping",
|
|
318
|
+
fixable: false,
|
|
319
|
+
severity: "info",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const result = await this.exec("npx", ["eslint", ".", "--quiet", "--format", "compact"], this.config.buildTimeout);
|
|
323
|
+
if (result.exitCode === 0) {
|
|
324
|
+
return {
|
|
325
|
+
name: "ESLint",
|
|
326
|
+
status: "pass",
|
|
327
|
+
message: "No lint errors",
|
|
328
|
+
fixable: false,
|
|
329
|
+
severity: "info",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const output = result.stdout + "\n" + result.stderr;
|
|
333
|
+
const errorMatch = output.match(/(\d+) error/);
|
|
334
|
+
const warnMatch = output.match(/(\d+) warning/);
|
|
335
|
+
const errorCount = errorMatch ? parseInt(errorMatch[1], 10) : 1;
|
|
336
|
+
const warnCount = warnMatch ? parseInt(warnMatch[1], 10) : 0;
|
|
337
|
+
const errorLines = output.split("\n").filter((l) => l.includes("Error"));
|
|
338
|
+
if (errorCount === 0 && warnCount > 0) {
|
|
339
|
+
return {
|
|
340
|
+
name: "ESLint",
|
|
341
|
+
status: "warn",
|
|
342
|
+
message: `${warnCount} warning(s)`,
|
|
343
|
+
details: errorLines.slice(0, 10),
|
|
344
|
+
fixable: this.config.fixableCategories.includes("lint"),
|
|
345
|
+
severity: "low",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
name: "ESLint",
|
|
350
|
+
status: "fail",
|
|
351
|
+
message: `${errorCount} error(s), ${warnCount} warning(s)`,
|
|
352
|
+
details: errorLines.slice(0, 20),
|
|
353
|
+
fixable: this.config.fixableCategories.includes("lint"),
|
|
354
|
+
severity: "high",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 순환 import 검사.
|
|
359
|
+
* 간이 검사: import 그래프를 구축하여 사이클 탐지.
|
|
360
|
+
*/
|
|
361
|
+
async checkCircularImports() {
|
|
362
|
+
const files = await this.collectSourceFiles();
|
|
363
|
+
const importGraph = new Map();
|
|
364
|
+
for (const file of files) {
|
|
365
|
+
try {
|
|
366
|
+
const content = await readFile(file, "utf-8");
|
|
367
|
+
const imports = new Set();
|
|
368
|
+
// Match import/export from "..." patterns
|
|
369
|
+
const importRegex = /(?:import|export)\s+.*?from\s+["']([^"']+)["']/g;
|
|
370
|
+
let match;
|
|
371
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
372
|
+
const importPath = match[1];
|
|
373
|
+
if (importPath.startsWith(".")) {
|
|
374
|
+
const resolved = this.resolveImportPath(file, importPath);
|
|
375
|
+
if (resolved)
|
|
376
|
+
imports.add(resolved);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
importGraph.set(file, imports);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Skip unreadable files
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// DFS cycle detection
|
|
386
|
+
const cycles = [];
|
|
387
|
+
const visited = new Set();
|
|
388
|
+
const inStack = new Set();
|
|
389
|
+
const dfs = (node, pathStack) => {
|
|
390
|
+
if (inStack.has(node)) {
|
|
391
|
+
const cycleStart = pathStack.indexOf(node);
|
|
392
|
+
if (cycleStart !== -1) {
|
|
393
|
+
cycles.push(pathStack.slice(cycleStart));
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (visited.has(node))
|
|
398
|
+
return;
|
|
399
|
+
visited.add(node);
|
|
400
|
+
inStack.add(node);
|
|
401
|
+
pathStack.push(node);
|
|
402
|
+
const deps = importGraph.get(node);
|
|
403
|
+
if (deps) {
|
|
404
|
+
for (const dep of deps) {
|
|
405
|
+
dfs(dep, [...pathStack]);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
inStack.delete(node);
|
|
409
|
+
};
|
|
410
|
+
for (const node of importGraph.keys()) {
|
|
411
|
+
dfs(node, []);
|
|
412
|
+
}
|
|
413
|
+
if (cycles.length === 0) {
|
|
414
|
+
return {
|
|
415
|
+
name: "Circular Imports",
|
|
416
|
+
status: "pass",
|
|
417
|
+
message: "No circular imports detected",
|
|
418
|
+
fixable: false,
|
|
419
|
+
severity: "info",
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const projectDir = this.config.projectPath;
|
|
423
|
+
const cycleDetails = cycles.slice(0, 5).map((cycle) => cycle.map((f) => path.relative(projectDir, f)).join(" → ") + " → (cycle)");
|
|
424
|
+
return {
|
|
425
|
+
name: "Circular Imports",
|
|
426
|
+
status: "warn",
|
|
427
|
+
message: `${cycles.length} circular import(s) detected`,
|
|
428
|
+
details: cycleDetails,
|
|
429
|
+
fixable: false,
|
|
430
|
+
severity: "medium",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Export 검사 — src/ 내 .ts 파일이 아무것도 export하지 않으면 경고.
|
|
435
|
+
*/
|
|
436
|
+
async checkExports() {
|
|
437
|
+
const files = await this.collectSourceFiles();
|
|
438
|
+
const noExportFiles = [];
|
|
439
|
+
for (const file of files) {
|
|
440
|
+
// Skip test files, type declaration files, index files
|
|
441
|
+
const basename = path.basename(file);
|
|
442
|
+
if (basename.includes(".test.") ||
|
|
443
|
+
basename.includes(".spec.") ||
|
|
444
|
+
basename.endsWith(".d.ts") ||
|
|
445
|
+
basename === "index.ts") {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const content = await readFile(file, "utf-8");
|
|
450
|
+
// Check for any export statement
|
|
451
|
+
if (!/\bexport\b/.test(content)) {
|
|
452
|
+
noExportFiles.push(path.relative(this.config.projectPath, file));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Skip unreadable files
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (noExportFiles.length === 0) {
|
|
460
|
+
return {
|
|
461
|
+
name: "Exports",
|
|
462
|
+
status: "pass",
|
|
463
|
+
message: "All source files have exports",
|
|
464
|
+
fixable: false,
|
|
465
|
+
severity: "info",
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
name: "Exports",
|
|
470
|
+
status: "warn",
|
|
471
|
+
message: `${noExportFiles.length} file(s) with no exports`,
|
|
472
|
+
details: noExportFiles.slice(0, 10),
|
|
473
|
+
fixable: false,
|
|
474
|
+
severity: "low",
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
// ─── Stage 2: Semantic Validation ───────────────────────────────────
|
|
478
|
+
/**
|
|
479
|
+
* Stage 2 — 시맨틱 검증: 테스트 실행.
|
|
480
|
+
*
|
|
481
|
+
* @param changedFiles 변경된 파일 목록 (있으면 관련 테스트만 실행)
|
|
482
|
+
*/
|
|
483
|
+
async runSemantic(changedFiles) {
|
|
484
|
+
const startTime = Date.now();
|
|
485
|
+
const checks = [];
|
|
486
|
+
this.emit("check:run", "Tests");
|
|
487
|
+
if (changedFiles && changedFiles.length > 0) {
|
|
488
|
+
const testResult = await this.runAffectedTests(changedFiles);
|
|
489
|
+
checks.push(testResult);
|
|
490
|
+
this.emit("check:result", testResult);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const testResult = await this.runAllTests();
|
|
494
|
+
checks.push(testResult);
|
|
495
|
+
this.emit("check:result", testResult);
|
|
496
|
+
}
|
|
497
|
+
return this.buildStageResult("semantic", checks, [], startTime);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* 변경된 파일에 대응하는 테스트만 실행.
|
|
501
|
+
*/
|
|
502
|
+
async runAffectedTests(changedFiles) {
|
|
503
|
+
// Find test files that match changed source files
|
|
504
|
+
const testPatterns = changedFiles.map((f) => {
|
|
505
|
+
const base = path.basename(f, path.extname(f));
|
|
506
|
+
return base;
|
|
507
|
+
});
|
|
508
|
+
// Try running tests with pattern filter
|
|
509
|
+
const hasTestRunner = await this.detectTestRunner();
|
|
510
|
+
if (!hasTestRunner) {
|
|
511
|
+
return {
|
|
512
|
+
name: "Affected Tests",
|
|
513
|
+
status: "pass",
|
|
514
|
+
message: "No test runner detected, skipping",
|
|
515
|
+
fixable: false,
|
|
516
|
+
severity: "info",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
// Use generic test command
|
|
520
|
+
const result = await this.exec("npx", ["--no", "vitest", "run", "--reporter=verbose", ...testPatterns.map((p) => `--testPathPattern=${p}`)], this.config.testTimeout);
|
|
521
|
+
// Fallback: try node --test
|
|
522
|
+
if (result.exitCode !== 0 && result.stderr.includes("not found")) {
|
|
523
|
+
const nodeResult = await this.exec("node", ["--test", ...changedFiles.filter((f) => f.includes(".test."))], this.config.testTimeout);
|
|
524
|
+
return this.parseTestResult("Affected Tests", nodeResult);
|
|
525
|
+
}
|
|
526
|
+
return this.parseTestResult("Affected Tests", result);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* 전체 테스트 스위트 실행.
|
|
530
|
+
*/
|
|
531
|
+
async runAllTests() {
|
|
532
|
+
const hasTestRunner = await this.detectTestRunner();
|
|
533
|
+
if (!hasTestRunner) {
|
|
534
|
+
return {
|
|
535
|
+
name: "Full Test Suite",
|
|
536
|
+
status: "pass",
|
|
537
|
+
message: "No test runner detected, skipping",
|
|
538
|
+
fixable: false,
|
|
539
|
+
severity: "info",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
// Try npm test
|
|
543
|
+
const result = await this.exec("npm", ["test", "--if-present"], this.config.testTimeout);
|
|
544
|
+
return this.parseTestResult("Full Test Suite", result);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* 테스트 러너 존재 여부 확인.
|
|
548
|
+
*/
|
|
549
|
+
async detectTestRunner() {
|
|
550
|
+
try {
|
|
551
|
+
const pkgPath = path.join(this.config.projectPath, "package.json");
|
|
552
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
553
|
+
return !!(pkg.scripts?.test &&
|
|
554
|
+
pkg.scripts.test !== 'echo "Error: no test specified" && exit 1');
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 테스트 실행 결과 파싱.
|
|
562
|
+
*/
|
|
563
|
+
parseTestResult(name, result) {
|
|
564
|
+
const output = result.stdout + "\n" + result.stderr;
|
|
565
|
+
if (result.exitCode === 0) {
|
|
566
|
+
const passMatch = output.match(/(\d+)\s*(?:passing|passed|tests?\s*passed)/i);
|
|
567
|
+
const count = passMatch ? passMatch[1] : "all";
|
|
568
|
+
return {
|
|
569
|
+
name,
|
|
570
|
+
status: "pass",
|
|
571
|
+
message: `${count} test(s) passed`,
|
|
572
|
+
fixable: false,
|
|
573
|
+
severity: "info",
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
const failMatch = output.match(/(\d+)\s*(?:failing|failed)/i);
|
|
577
|
+
const failCount = failMatch ? failMatch[1] : "some";
|
|
578
|
+
return {
|
|
579
|
+
name,
|
|
580
|
+
status: "fail",
|
|
581
|
+
message: `${failCount} test(s) failed`,
|
|
582
|
+
details: output.split("\n").filter((l) => /fail|error|✗|✘|×/i.test(l)).slice(0, 15),
|
|
583
|
+
fixable: false,
|
|
584
|
+
severity: "high",
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
// ─── Stage 3: Quality Gates ─────────────────────────────────────────
|
|
588
|
+
/**
|
|
589
|
+
* Stage 3 — 품질 게이트: 복잡도, 길이, TODO, 디버그 문, 보안 스캔.
|
|
590
|
+
*
|
|
591
|
+
* @param changedFiles 변경된 파일 목록 (없으면 전체 소스)
|
|
592
|
+
*/
|
|
593
|
+
async runQuality(changedFiles) {
|
|
594
|
+
const startTime = Date.now();
|
|
595
|
+
const checks = [];
|
|
596
|
+
const files = changedFiles?.length
|
|
597
|
+
? changedFiles.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx") || f.endsWith(".js") || f.endsWith(".jsx"))
|
|
598
|
+
: await this.collectSourceFiles();
|
|
599
|
+
this.emit("check:run", "Cyclomatic Complexity");
|
|
600
|
+
const complexityCheck = await this.checkComplexity(files);
|
|
601
|
+
checks.push(complexityCheck);
|
|
602
|
+
this.emit("check:result", complexityCheck);
|
|
603
|
+
this.emit("check:run", "Function/File Lengths");
|
|
604
|
+
const lengthCheck = await this.checkLengths(files);
|
|
605
|
+
checks.push(lengthCheck);
|
|
606
|
+
this.emit("check:result", lengthCheck);
|
|
607
|
+
this.emit("check:run", "TODO Count");
|
|
608
|
+
const todoCheck = await this.checkTodos(files);
|
|
609
|
+
checks.push(todoCheck);
|
|
610
|
+
this.emit("check:result", todoCheck);
|
|
611
|
+
this.emit("check:run", "Debug Statements");
|
|
612
|
+
const debugCheck = await this.checkDebugStatements(files);
|
|
613
|
+
checks.push(debugCheck);
|
|
614
|
+
this.emit("check:result", debugCheck);
|
|
615
|
+
this.emit("check:run", "Security Scan");
|
|
616
|
+
const securityCheck = await this.checkSecurity(files);
|
|
617
|
+
checks.push(securityCheck);
|
|
618
|
+
this.emit("check:result", securityCheck);
|
|
619
|
+
return this.buildStageResult("quality", checks, [], startTime);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* 순환 복잡도 검사.
|
|
623
|
+
*/
|
|
624
|
+
async checkComplexity(files) {
|
|
625
|
+
const violations = [];
|
|
626
|
+
let maxFound = 0;
|
|
627
|
+
for (const file of files) {
|
|
628
|
+
try {
|
|
629
|
+
const content = await readFile(file, "utf-8");
|
|
630
|
+
const metrics = this.analyzeFileMetrics(content);
|
|
631
|
+
for (const fn of metrics.functions) {
|
|
632
|
+
if (fn.complexity > maxFound)
|
|
633
|
+
maxFound = fn.complexity;
|
|
634
|
+
if (fn.complexity > this.config.qualityGates.maxCyclomaticComplexity) {
|
|
635
|
+
violations.push(`${path.relative(this.config.projectPath, file)}: ${fn.name}() complexity=${fn.complexity} (max ${this.config.qualityGates.maxCyclomaticComplexity})`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// Skip unreadable files
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (violations.length === 0) {
|
|
644
|
+
return {
|
|
645
|
+
name: "Cyclomatic Complexity",
|
|
646
|
+
status: "pass",
|
|
647
|
+
message: `Max complexity: ${maxFound} (threshold: ${this.config.qualityGates.maxCyclomaticComplexity})`,
|
|
648
|
+
fixable: false,
|
|
649
|
+
severity: "info",
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
name: "Cyclomatic Complexity",
|
|
654
|
+
status: "warn",
|
|
655
|
+
message: `${violations.length} function(s) exceed complexity threshold`,
|
|
656
|
+
details: violations.slice(0, 10),
|
|
657
|
+
fixable: false,
|
|
658
|
+
severity: "medium",
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* 함수/파일 길이 검사.
|
|
663
|
+
*/
|
|
664
|
+
async checkLengths(files) {
|
|
665
|
+
const violations = [];
|
|
666
|
+
for (const file of files) {
|
|
667
|
+
try {
|
|
668
|
+
const content = await readFile(file, "utf-8");
|
|
669
|
+
const lines = content.split("\n");
|
|
670
|
+
const relPath = path.relative(this.config.projectPath, file);
|
|
671
|
+
// File length check
|
|
672
|
+
if (lines.length > this.config.qualityGates.maxFileLength) {
|
|
673
|
+
violations.push(`${relPath}: ${lines.length} lines (max ${this.config.qualityGates.maxFileLength})`);
|
|
674
|
+
}
|
|
675
|
+
// Function length check
|
|
676
|
+
const metrics = this.analyzeFileMetrics(content);
|
|
677
|
+
for (const fn of metrics.functions) {
|
|
678
|
+
if (fn.length > this.config.qualityGates.maxFunctionLength) {
|
|
679
|
+
violations.push(`${relPath}: ${fn.name}() is ${fn.length} lines (max ${this.config.qualityGates.maxFunctionLength})`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Skip unreadable files
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (violations.length === 0) {
|
|
688
|
+
return {
|
|
689
|
+
name: "Function/File Lengths",
|
|
690
|
+
status: "pass",
|
|
691
|
+
message: "All functions and files within length limits",
|
|
692
|
+
fixable: false,
|
|
693
|
+
severity: "info",
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
name: "Function/File Lengths",
|
|
698
|
+
status: "warn",
|
|
699
|
+
message: `${violations.length} length violation(s)`,
|
|
700
|
+
details: violations.slice(0, 10),
|
|
701
|
+
fixable: false,
|
|
702
|
+
severity: "low",
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* TODO/FIXME/HACK 검사.
|
|
707
|
+
*/
|
|
708
|
+
async checkTodos(files) {
|
|
709
|
+
if (this.config.qualityGates.maxTodoCount < 0) {
|
|
710
|
+
return {
|
|
711
|
+
name: "TODO Count",
|
|
712
|
+
status: "pass",
|
|
713
|
+
message: "TODO check disabled",
|
|
714
|
+
fixable: false,
|
|
715
|
+
severity: "info",
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
let totalTodos = 0;
|
|
719
|
+
const todoLocations = [];
|
|
720
|
+
for (const file of files) {
|
|
721
|
+
try {
|
|
722
|
+
const content = await readFile(file, "utf-8");
|
|
723
|
+
const lines = content.split("\n");
|
|
724
|
+
for (let i = 0; i < lines.length; i++) {
|
|
725
|
+
if (/\b(TODO|FIXME|HACK|XXX)\b/i.test(lines[i])) {
|
|
726
|
+
totalTodos++;
|
|
727
|
+
if (todoLocations.length < 10) {
|
|
728
|
+
todoLocations.push(`${path.relative(this.config.projectPath, file)}:${i + 1}: ${lines[i].trim().substring(0, 80)}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// Skip
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (totalTodos <= this.config.qualityGates.maxTodoCount) {
|
|
738
|
+
return {
|
|
739
|
+
name: "TODO Count",
|
|
740
|
+
status: "pass",
|
|
741
|
+
message: `${totalTodos} TODO(s) found (max ${this.config.qualityGates.maxTodoCount})`,
|
|
742
|
+
fixable: false,
|
|
743
|
+
severity: "info",
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
name: "TODO Count",
|
|
748
|
+
status: "warn",
|
|
749
|
+
message: `${totalTodos} TODO(s) found (max ${this.config.qualityGates.maxTodoCount})`,
|
|
750
|
+
details: todoLocations,
|
|
751
|
+
fixable: false,
|
|
752
|
+
severity: "low",
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* 디버그 문 검사 (console.log 등, 테스트 파일 제외).
|
|
757
|
+
*/
|
|
758
|
+
async checkDebugStatements(files) {
|
|
759
|
+
const violations = [];
|
|
760
|
+
for (const file of files) {
|
|
761
|
+
// Skip test files
|
|
762
|
+
if (file.includes(".test.") || file.includes(".spec.") || file.includes("__tests__")) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const content = await readFile(file, "utf-8");
|
|
767
|
+
const lines = content.split("\n");
|
|
768
|
+
for (let i = 0; i < lines.length; i++) {
|
|
769
|
+
const line = lines[i];
|
|
770
|
+
// Match console.log/warn/error/debug/info but not commented out
|
|
771
|
+
if (/^\s*console\.(log|debug|info)\s*\(/.test(line) && !line.trimStart().startsWith("//")) {
|
|
772
|
+
violations.push(`${path.relative(this.config.projectPath, file)}:${i + 1}: ${line.trim().substring(0, 80)}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// Skip
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (violations.length === 0) {
|
|
781
|
+
return {
|
|
782
|
+
name: "Debug Statements",
|
|
783
|
+
status: "pass",
|
|
784
|
+
message: "No debug statements found",
|
|
785
|
+
fixable: true,
|
|
786
|
+
severity: "info",
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
name: "Debug Statements",
|
|
791
|
+
status: "warn",
|
|
792
|
+
message: `${violations.length} debug statement(s) found`,
|
|
793
|
+
details: violations.slice(0, 10),
|
|
794
|
+
fixable: true,
|
|
795
|
+
severity: "low",
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* 보안 퀵 스캔 — 하드코딩된 시크릿, eval, SQL 인젝션 등.
|
|
800
|
+
*/
|
|
801
|
+
async checkSecurity(files) {
|
|
802
|
+
const findings = [];
|
|
803
|
+
for (const file of files) {
|
|
804
|
+
// Skip test files and node_modules
|
|
805
|
+
if (file.includes("node_modules") || file.includes(".test.") || file.includes(".spec.")) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const content = await readFile(file, "utf-8");
|
|
810
|
+
const lines = content.split("\n");
|
|
811
|
+
for (const { name, pattern, severity } of SECURITY_PATTERNS) {
|
|
812
|
+
// Reset lastIndex for global patterns
|
|
813
|
+
pattern.lastIndex = 0;
|
|
814
|
+
for (let i = 0; i < lines.length; i++) {
|
|
815
|
+
pattern.lastIndex = 0;
|
|
816
|
+
if (pattern.test(lines[i]) && !lines[i].trimStart().startsWith("//")) {
|
|
817
|
+
findings.push({
|
|
818
|
+
pattern: name,
|
|
819
|
+
file: path.relative(this.config.projectPath, file),
|
|
820
|
+
line: i + 1,
|
|
821
|
+
severity,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
// Skip
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (findings.length === 0) {
|
|
832
|
+
return {
|
|
833
|
+
name: "Security Scan",
|
|
834
|
+
status: "pass",
|
|
835
|
+
message: "No security issues found",
|
|
836
|
+
fixable: false,
|
|
837
|
+
severity: "info",
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
const hasCritical = findings.some((f) => f.severity === "critical");
|
|
841
|
+
const details = findings
|
|
842
|
+
.slice(0, 15)
|
|
843
|
+
.map((f) => `[${f.severity.toUpperCase()}] ${f.file}:${f.line} — ${f.pattern}`);
|
|
844
|
+
return {
|
|
845
|
+
name: "Security Scan",
|
|
846
|
+
status: hasCritical ? "fail" : "warn",
|
|
847
|
+
message: `${findings.length} security finding(s)`,
|
|
848
|
+
details,
|
|
849
|
+
fixable: false,
|
|
850
|
+
severity: hasCritical ? "critical" : "medium",
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
// ─── Stage 4: Review Agent ──────────────────────────────────────────
|
|
854
|
+
/**
|
|
855
|
+
* Stage 4 — LLM 기반 코드 리뷰 (thorough 모드 전용).
|
|
856
|
+
*
|
|
857
|
+
* @param changedFiles 변경된 파일 목록
|
|
858
|
+
* @param reviewFn 리뷰를 수행할 LLM 함수
|
|
859
|
+
*/
|
|
860
|
+
async runReview(changedFiles, reviewFn) {
|
|
861
|
+
const startTime = Date.now();
|
|
862
|
+
const checks = [];
|
|
863
|
+
try {
|
|
864
|
+
const fileContents = await this.readFiles(changedFiles);
|
|
865
|
+
if (fileContents.size === 0) {
|
|
866
|
+
return this.buildStageResult("review", [], [], startTime, "skip");
|
|
867
|
+
}
|
|
868
|
+
const prompt = this.buildReviewPrompt(fileContents);
|
|
869
|
+
this.emit("check:run", "LLM Code Review");
|
|
870
|
+
const response = await reviewFn(prompt);
|
|
871
|
+
const reviewChecks = this.parseReviewResponse(response);
|
|
872
|
+
checks.push(...reviewChecks);
|
|
873
|
+
for (const check of reviewChecks) {
|
|
874
|
+
this.emit("check:result", check);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
checks.push({
|
|
879
|
+
name: "LLM Code Review",
|
|
880
|
+
status: "warn",
|
|
881
|
+
message: `Review failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
882
|
+
fixable: false,
|
|
883
|
+
severity: "low",
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return this.buildStageResult("review", checks, [], startTime);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* 리뷰 프롬프트 생성.
|
|
890
|
+
*/
|
|
891
|
+
buildReviewPrompt(files) {
|
|
892
|
+
let prompt = `You are a code reviewer. Review the following changed files and provide findings.\n\n`;
|
|
893
|
+
prompt += `For each issue found, respond with a line in this exact format:\n`;
|
|
894
|
+
prompt += `[SEVERITY:STATUS] file:line message\n\n`;
|
|
895
|
+
prompt += `Where:\n`;
|
|
896
|
+
prompt += `- SEVERITY is one of: critical, high, medium, low, info\n`;
|
|
897
|
+
prompt += `- STATUS is one of: fail, warn, pass\n`;
|
|
898
|
+
prompt += `- file is the relative file path\n`;
|
|
899
|
+
prompt += `- line is the line number (0 if not applicable)\n\n`;
|
|
900
|
+
prompt += `Focus on:\n`;
|
|
901
|
+
prompt += `- Logic errors and edge cases\n`;
|
|
902
|
+
prompt += `- Security vulnerabilities\n`;
|
|
903
|
+
prompt += `- Performance issues\n`;
|
|
904
|
+
prompt += `- API contract violations\n`;
|
|
905
|
+
prompt += `- Missing error handling\n\n`;
|
|
906
|
+
prompt += `If the code looks good, respond with:\n`;
|
|
907
|
+
prompt += `[info:pass] overall:0 Code review passed, no issues found.\n\n`;
|
|
908
|
+
prompt += `--- Files ---\n\n`;
|
|
909
|
+
for (const [filePath, content] of files) {
|
|
910
|
+
prompt += `### ${filePath}\n\`\`\`typescript\n${content.substring(0, 8000)}\n\`\`\`\n\n`;
|
|
911
|
+
}
|
|
912
|
+
return prompt;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* LLM 리뷰 응답 파싱.
|
|
916
|
+
*/
|
|
917
|
+
parseReviewResponse(response) {
|
|
918
|
+
const checks = [];
|
|
919
|
+
const linePattern = /\[(critical|high|medium|low|info):(fail|warn|pass)\]\s+([^:]+):(\d+)\s+(.+)/gi;
|
|
920
|
+
let match;
|
|
921
|
+
while ((match = linePattern.exec(response)) !== null) {
|
|
922
|
+
const severity = match[1].toLowerCase();
|
|
923
|
+
const status = match[2].toLowerCase();
|
|
924
|
+
const file = match[3].trim();
|
|
925
|
+
const line = parseInt(match[4], 10);
|
|
926
|
+
const message = match[5].trim();
|
|
927
|
+
checks.push({
|
|
928
|
+
name: "LLM Code Review",
|
|
929
|
+
status,
|
|
930
|
+
message,
|
|
931
|
+
file: file === "overall" ? undefined : file,
|
|
932
|
+
line: line > 0 ? line : undefined,
|
|
933
|
+
fixable: false,
|
|
934
|
+
severity,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
// If no structured output found, treat whole response as a single pass
|
|
938
|
+
if (checks.length === 0) {
|
|
939
|
+
checks.push({
|
|
940
|
+
name: "LLM Code Review",
|
|
941
|
+
status: "pass",
|
|
942
|
+
message: response.substring(0, 200).trim() || "Review completed",
|
|
943
|
+
fixable: false,
|
|
944
|
+
severity: "info",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return checks;
|
|
948
|
+
}
|
|
949
|
+
// ─── Stage 5: Decision ──────────────────────────────────────────────
|
|
950
|
+
/**
|
|
951
|
+
* Stage 5 — 전체 결과 기반 자동 판정.
|
|
952
|
+
*
|
|
953
|
+
* - Critical failure → "escalate"
|
|
954
|
+
* - Only fixable failures → "fix_and_retry"
|
|
955
|
+
* - All pass or only warnings → "approve"
|
|
956
|
+
*/
|
|
957
|
+
makeDecision(stages) {
|
|
958
|
+
const allChecks = stages.flatMap((s) => s.checks);
|
|
959
|
+
const failures = allChecks.filter((c) => c.status === "fail");
|
|
960
|
+
const criticalIssues = failures.filter((c) => c.severity === "critical");
|
|
961
|
+
const suggestions = [];
|
|
962
|
+
// Collect warnings as suggestions
|
|
963
|
+
const warnings = allChecks.filter((c) => c.status === "warn");
|
|
964
|
+
for (const w of warnings.slice(0, 5)) {
|
|
965
|
+
suggestions.push(`[${w.severity}] ${w.name}: ${w.message}`);
|
|
966
|
+
}
|
|
967
|
+
// Any critical failure → escalate
|
|
968
|
+
if (criticalIssues.length > 0) {
|
|
969
|
+
return {
|
|
970
|
+
action: "escalate",
|
|
971
|
+
reason: `${criticalIssues.length} critical issue(s) require human review`,
|
|
972
|
+
criticalIssues,
|
|
973
|
+
suggestions,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
// Only fixable failures → fix_and_retry
|
|
977
|
+
if (failures.length > 0) {
|
|
978
|
+
const allFixable = failures.every((f) => f.fixable);
|
|
979
|
+
if (allFixable) {
|
|
980
|
+
return {
|
|
981
|
+
action: "fix_and_retry",
|
|
982
|
+
reason: `${failures.length} fixable issue(s) detected`,
|
|
983
|
+
criticalIssues: [],
|
|
984
|
+
suggestions: [
|
|
985
|
+
...failures.map((f) => `Fix: ${f.name} — ${f.message}`),
|
|
986
|
+
...suggestions,
|
|
987
|
+
],
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
// Non-fixable failures → escalate
|
|
991
|
+
return {
|
|
992
|
+
action: "escalate",
|
|
993
|
+
reason: `${failures.length} failure(s), some not auto-fixable`,
|
|
994
|
+
criticalIssues: failures.filter((f) => !f.fixable),
|
|
995
|
+
suggestions,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
// All pass or only warnings → approve
|
|
999
|
+
return {
|
|
1000
|
+
action: "approve",
|
|
1001
|
+
reason: warnings.length > 0
|
|
1002
|
+
? `All checks passed with ${warnings.length} warning(s)`
|
|
1003
|
+
: "All checks passed",
|
|
1004
|
+
criticalIssues: [],
|
|
1005
|
+
suggestions,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
// ─── Auto-Fix ───────────────────────────────────────────────────────
|
|
1009
|
+
/**
|
|
1010
|
+
* 실패한 검사를 자동 수정 시도.
|
|
1011
|
+
*
|
|
1012
|
+
* @param check 실패한 검사 결과
|
|
1013
|
+
* @param attempt 시도 번호 (1-based)
|
|
1014
|
+
*/
|
|
1015
|
+
async attemptFix(check, attempt) {
|
|
1016
|
+
const base = {
|
|
1017
|
+
check: check.name,
|
|
1018
|
+
attempt,
|
|
1019
|
+
success: false,
|
|
1020
|
+
description: "",
|
|
1021
|
+
};
|
|
1022
|
+
try {
|
|
1023
|
+
switch (check.name) {
|
|
1024
|
+
case "ESLint": {
|
|
1025
|
+
const fixed = await this.fixLint();
|
|
1026
|
+
return { ...base, success: fixed, description: fixed ? "Ran eslint --fix" : "eslint --fix failed" };
|
|
1027
|
+
}
|
|
1028
|
+
case "TypeScript Compilation": {
|
|
1029
|
+
// TypeScript errors can't be auto-fixed easily, but we can try fixing imports
|
|
1030
|
+
const fixed = await this.fixImports();
|
|
1031
|
+
return { ...base, success: fixed, description: fixed ? "Fixed import issues" : "Could not auto-fix TS errors" };
|
|
1032
|
+
}
|
|
1033
|
+
default:
|
|
1034
|
+
return { ...base, success: false, description: `No auto-fix available for ${check.name}` };
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch (err) {
|
|
1038
|
+
return {
|
|
1039
|
+
...base,
|
|
1040
|
+
success: false,
|
|
1041
|
+
description: `Fix error: ${err instanceof Error ? err.message : String(err)}`,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* ESLint --fix 실행.
|
|
1047
|
+
*/
|
|
1048
|
+
async fixLint() {
|
|
1049
|
+
const hasEslint = await this.fileExists(path.join(this.config.projectPath, "node_modules/.bin/eslint"));
|
|
1050
|
+
if (!hasEslint)
|
|
1051
|
+
return false;
|
|
1052
|
+
const result = await this.exec("npx", ["eslint", ".", "--fix", "--quiet"], this.config.buildTimeout);
|
|
1053
|
+
return result.exitCode === 0;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Import 에러 수정 시도 (간이: 미사용 import 제거).
|
|
1057
|
+
*/
|
|
1058
|
+
async fixImports() {
|
|
1059
|
+
// TypeScript 에러를 직접 수정하기는 어려우므로,
|
|
1060
|
+
// tsc --noEmit 재실행하여 변화 확인만 수행
|
|
1061
|
+
const result = await this.exec("npx", ["tsc", "--noEmit"], this.config.buildTimeout);
|
|
1062
|
+
return result.exitCode === 0;
|
|
1063
|
+
}
|
|
1064
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
1065
|
+
/**
|
|
1066
|
+
* 셸 명령 실행 (타임아웃 포함).
|
|
1067
|
+
*/
|
|
1068
|
+
exec(command, args, timeout) {
|
|
1069
|
+
return new Promise((resolve) => {
|
|
1070
|
+
execFile(command, args, {
|
|
1071
|
+
cwd: this.config.projectPath,
|
|
1072
|
+
timeout,
|
|
1073
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
1074
|
+
env: { ...process.env, FORCE_COLOR: "0", NODE_ENV: "test" },
|
|
1075
|
+
}, (error, stdout, stderr) => {
|
|
1076
|
+
let exitCode = 0;
|
|
1077
|
+
if (error) {
|
|
1078
|
+
exitCode = typeof error.code === "number"
|
|
1079
|
+
? error.code
|
|
1080
|
+
: error.status ?? 1;
|
|
1081
|
+
}
|
|
1082
|
+
resolve({
|
|
1083
|
+
stdout: (stdout ?? "").toString(),
|
|
1084
|
+
stderr: (stderr ?? "").toString(),
|
|
1085
|
+
exitCode,
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* 파일 목록을 읽어 Map<경로, 내용>으로 반환.
|
|
1092
|
+
*/
|
|
1093
|
+
async readFiles(files) {
|
|
1094
|
+
const result = new Map();
|
|
1095
|
+
for (const file of files) {
|
|
1096
|
+
try {
|
|
1097
|
+
const absPath = path.isAbsolute(file)
|
|
1098
|
+
? file
|
|
1099
|
+
: path.join(this.config.projectPath, file);
|
|
1100
|
+
const content = await readFile(absPath, "utf-8");
|
|
1101
|
+
result.set(path.relative(this.config.projectPath, absPath), content);
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
// Skip unreadable files
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return result;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* src/ 디렉토리 내 모든 .ts/.tsx 소스 파일 수집.
|
|
1111
|
+
*/
|
|
1112
|
+
async collectSourceFiles() {
|
|
1113
|
+
const srcDir = path.join(this.config.projectPath, "src");
|
|
1114
|
+
const hasSrc = await this.fileExists(srcDir);
|
|
1115
|
+
const baseDir = hasSrc ? srcDir : this.config.projectPath;
|
|
1116
|
+
const files = [];
|
|
1117
|
+
await this.walkDir(baseDir, files);
|
|
1118
|
+
return files;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* 디렉토리 재귀 탐색.
|
|
1122
|
+
*/
|
|
1123
|
+
async walkDir(dir, out) {
|
|
1124
|
+
try {
|
|
1125
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1126
|
+
for (const entry of entries) {
|
|
1127
|
+
const fullPath = path.join(dir, entry.name);
|
|
1128
|
+
if (entry.isDirectory()) {
|
|
1129
|
+
// Skip known non-source directories
|
|
1130
|
+
if (entry.name === "node_modules" ||
|
|
1131
|
+
entry.name === "dist" ||
|
|
1132
|
+
entry.name === ".git" ||
|
|
1133
|
+
entry.name === "coverage" ||
|
|
1134
|
+
entry.name === ".next") {
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
await this.walkDir(fullPath, out);
|
|
1138
|
+
}
|
|
1139
|
+
else if (entry.isFile() &&
|
|
1140
|
+
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx") ||
|
|
1141
|
+
entry.name.endsWith(".js") || entry.name.endsWith(".jsx"))) {
|
|
1142
|
+
out.push(fullPath);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
// Skip inaccessible directories
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* 파일 메트릭 분석 — 순환 복잡도, 인지 복잡도, LOC, 함수 목록.
|
|
1152
|
+
*/
|
|
1153
|
+
analyzeFileMetrics(content) {
|
|
1154
|
+
const lines = content.split("\n");
|
|
1155
|
+
const loc = lines.length;
|
|
1156
|
+
const functions = [];
|
|
1157
|
+
// Simple function boundary detection by brace counting
|
|
1158
|
+
let currentFunction = null;
|
|
1159
|
+
let braceDepth = 0;
|
|
1160
|
+
let inBlockComment = false;
|
|
1161
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1162
|
+
const line = lines[i];
|
|
1163
|
+
const trimmed = line.trim();
|
|
1164
|
+
// Handle block comments
|
|
1165
|
+
if (inBlockComment) {
|
|
1166
|
+
if (trimmed.includes("*/"))
|
|
1167
|
+
inBlockComment = false;
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
if (trimmed.startsWith("/*")) {
|
|
1171
|
+
if (!trimmed.includes("*/"))
|
|
1172
|
+
inBlockComment = true;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
// Skip single-line comments
|
|
1176
|
+
if (trimmed.startsWith("//"))
|
|
1177
|
+
continue;
|
|
1178
|
+
// Detect function start
|
|
1179
|
+
const funcMatch = trimmed.match(/(?:async\s+)?function\s+(\w+)/) ||
|
|
1180
|
+
trimmed.match(/(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/) ||
|
|
1181
|
+
trimmed.match(/(\w+)\s*\([^)]*\)\s*(?::\s*\S+\s*)?\{/) ||
|
|
1182
|
+
trimmed.match(/(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/);
|
|
1183
|
+
if (funcMatch && !currentFunction && trimmed.includes("{")) {
|
|
1184
|
+
currentFunction = {
|
|
1185
|
+
name: funcMatch[1],
|
|
1186
|
+
startLine: i,
|
|
1187
|
+
braceDepth,
|
|
1188
|
+
complexity: 1, // Base complexity
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
// Count braces
|
|
1192
|
+
for (const ch of line) {
|
|
1193
|
+
if (ch === "{")
|
|
1194
|
+
braceDepth++;
|
|
1195
|
+
else if (ch === "}")
|
|
1196
|
+
braceDepth--;
|
|
1197
|
+
}
|
|
1198
|
+
// Count complexity within current function
|
|
1199
|
+
if (currentFunction) {
|
|
1200
|
+
COMPLEXITY_KEYWORDS.lastIndex = 0;
|
|
1201
|
+
const kwMatches = trimmed.match(COMPLEXITY_KEYWORDS);
|
|
1202
|
+
if (kwMatches)
|
|
1203
|
+
currentFunction.complexity += kwMatches.length;
|
|
1204
|
+
LOGICAL_OPERATORS.lastIndex = 0;
|
|
1205
|
+
const logicalMatches = trimmed.match(LOGICAL_OPERATORS);
|
|
1206
|
+
if (logicalMatches)
|
|
1207
|
+
currentFunction.complexity += logicalMatches.length;
|
|
1208
|
+
TERNARY_OPERATOR.lastIndex = 0;
|
|
1209
|
+
const ternaryMatches = trimmed.match(TERNARY_OPERATOR);
|
|
1210
|
+
if (ternaryMatches)
|
|
1211
|
+
currentFunction.complexity += ternaryMatches.length;
|
|
1212
|
+
// Check function end
|
|
1213
|
+
if (braceDepth <= currentFunction.braceDepth) {
|
|
1214
|
+
functions.push({
|
|
1215
|
+
name: currentFunction.name,
|
|
1216
|
+
length: i - currentFunction.startLine + 1,
|
|
1217
|
+
complexity: currentFunction.complexity,
|
|
1218
|
+
});
|
|
1219
|
+
currentFunction = null;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
// If function never closed (incomplete parse), record it
|
|
1224
|
+
if (currentFunction) {
|
|
1225
|
+
functions.push({
|
|
1226
|
+
name: currentFunction.name,
|
|
1227
|
+
length: lines.length - currentFunction.startLine,
|
|
1228
|
+
complexity: currentFunction.complexity,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
// Calculate file-level complexity
|
|
1232
|
+
let cyclomatic = 1;
|
|
1233
|
+
let cognitive = 0;
|
|
1234
|
+
COMPLEXITY_KEYWORDS.lastIndex = 0;
|
|
1235
|
+
const allKeywords = content.match(COMPLEXITY_KEYWORDS);
|
|
1236
|
+
if (allKeywords)
|
|
1237
|
+
cyclomatic += allKeywords.length;
|
|
1238
|
+
LOGICAL_OPERATORS.lastIndex = 0;
|
|
1239
|
+
const allLogical = content.match(LOGICAL_OPERATORS);
|
|
1240
|
+
if (allLogical)
|
|
1241
|
+
cyclomatic += allLogical.length;
|
|
1242
|
+
// Cognitive: nesting awareness (simplified)
|
|
1243
|
+
let nestLevel = 0;
|
|
1244
|
+
for (const line of lines) {
|
|
1245
|
+
const trimmed = line.trim();
|
|
1246
|
+
if (/\b(if|for|while|switch)\b/.test(trimmed)) {
|
|
1247
|
+
cognitive += 1 + nestLevel;
|
|
1248
|
+
}
|
|
1249
|
+
for (const ch of line) {
|
|
1250
|
+
if (ch === "{")
|
|
1251
|
+
nestLevel++;
|
|
1252
|
+
else if (ch === "}")
|
|
1253
|
+
nestLevel = Math.max(0, nestLevel - 1);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return { cyclomatic, cognitive, loc, functions };
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* stage 결과 생성 헬퍼.
|
|
1260
|
+
*/
|
|
1261
|
+
buildStageResult(stage, checks, autoFixed, startTime, forceStatus) {
|
|
1262
|
+
const duration = Date.now() - startTime;
|
|
1263
|
+
let status;
|
|
1264
|
+
if (forceStatus) {
|
|
1265
|
+
status = forceStatus;
|
|
1266
|
+
}
|
|
1267
|
+
else if (checks.length === 0) {
|
|
1268
|
+
status = "pass";
|
|
1269
|
+
}
|
|
1270
|
+
else if (checks.some((c) => c.status === "fail")) {
|
|
1271
|
+
status = "fail";
|
|
1272
|
+
}
|
|
1273
|
+
else if (checks.some((c) => c.status === "warn")) {
|
|
1274
|
+
status = "warn";
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
status = "pass";
|
|
1278
|
+
}
|
|
1279
|
+
const passCount = checks.filter((c) => c.status === "pass").length;
|
|
1280
|
+
const warnCount = checks.filter((c) => c.status === "warn").length;
|
|
1281
|
+
const failCount = checks.filter((c) => c.status === "fail").length;
|
|
1282
|
+
const summary = `${stage}: ${passCount} passed, ${warnCount} warnings, ${failCount} failed (${duration}ms)`;
|
|
1283
|
+
return { stage, status, duration, checks, autoFixed, summary };
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* 전체 판정 결정.
|
|
1287
|
+
*/
|
|
1288
|
+
determineOverall(stages) {
|
|
1289
|
+
if (stages.some((s) => s.status === "fail"))
|
|
1290
|
+
return "fail";
|
|
1291
|
+
if (stages.some((s) => s.status === "warn"))
|
|
1292
|
+
return "warn";
|
|
1293
|
+
return "pass";
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Critical failure 존재 여부 확인.
|
|
1297
|
+
*/
|
|
1298
|
+
hasCriticalFailure(stages) {
|
|
1299
|
+
return stages.some((s) => s.status === "fail" &&
|
|
1300
|
+
s.checks.some((c) => c.status === "fail" && c.severity === "critical"));
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Quality stage 결과에서 GateResult 추출.
|
|
1304
|
+
*/
|
|
1305
|
+
collectGateResults(stageResult) {
|
|
1306
|
+
const gates = [];
|
|
1307
|
+
for (const check of stageResult.checks) {
|
|
1308
|
+
if (check.name === "Cyclomatic Complexity") {
|
|
1309
|
+
// Parse max complexity from message
|
|
1310
|
+
const match = check.message.match(/Max complexity:\s*(\d+)/);
|
|
1311
|
+
if (match) {
|
|
1312
|
+
gates.push({
|
|
1313
|
+
gate: "maxCyclomaticComplexity",
|
|
1314
|
+
threshold: this.config.qualityGates.maxCyclomaticComplexity,
|
|
1315
|
+
actual: parseInt(match[1], 10),
|
|
1316
|
+
passed: check.status !== "fail",
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return gates;
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Import 경로 해석 (상대 경로 → 절대 경로).
|
|
1325
|
+
*/
|
|
1326
|
+
resolveImportPath(fromFile, importPath) {
|
|
1327
|
+
const dir = path.dirname(fromFile);
|
|
1328
|
+
let resolved = path.resolve(dir, importPath);
|
|
1329
|
+
// Add .ts extension if missing
|
|
1330
|
+
if (!path.extname(resolved)) {
|
|
1331
|
+
resolved += ".ts";
|
|
1332
|
+
}
|
|
1333
|
+
// Remove .js and try .ts
|
|
1334
|
+
if (resolved.endsWith(".js")) {
|
|
1335
|
+
resolved = resolved.slice(0, -3) + ".ts";
|
|
1336
|
+
}
|
|
1337
|
+
return resolved;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* 파일 존재 확인.
|
|
1341
|
+
*/
|
|
1342
|
+
async fileExists(filePath) {
|
|
1343
|
+
try {
|
|
1344
|
+
await access(filePath, constants.F_OK);
|
|
1345
|
+
return true;
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
//# sourceMappingURL=qa-pipeline.js.map
|