ciforge-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/PKG-INFO +6 -3
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/README.md +5 -2
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/pyproject.toml +1 -1
- ciforge_cli-0.2.0/src/ciforge/ai_reviewer.py +50 -0
- ciforge_cli-0.2.0/src/ciforge/assets.py +25 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge/cli.py +5 -1
- ciforge_cli-0.2.0/src/ciforge/code_quality.py +64 -0
- ciforge_cli-0.2.0/src/ciforge/l10n.py +39 -0
- ciforge_cli-0.2.0/src/ciforge/metrics.py +26 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge_cli.egg-info/PKG-INFO +6 -3
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge_cli.egg-info/SOURCES.txt +4 -0
- ciforge_cli-0.2.0/tests/test_ciforge.py +122 -0
- ciforge_cli-0.1.0/src/ciforge/code_quality.py +0 -36
- ciforge_cli-0.1.0/tests/test_ciforge.py +0 -70
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/LICENSE +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/setup.cfg +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge/__init__.py +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge/config_validator.py +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge/coverage.py +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge/scanner.py +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge/secrets.py +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge_cli.egg-info/dependency_links.txt +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge_cli.egg-info/entry_points.txt +0 -0
- {ciforge_cli-0.1.0 → ciforge_cli-0.2.0}/src/ciforge_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ciforge-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A zero-dependency CI tool replacing multiple CI services.
|
|
5
5
|
Keywords: ci,github-actions,code-quality,coverage,linting
|
|
6
6
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -23,11 +23,14 @@ Dynamic: license-file
|
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
|
-
- **Code Quality**: Intelligent linting and complexity analysis
|
|
26
|
+
- **Code Quality**: Intelligent linting and AST-based complexity analysis (cyclomatic complexity, long functions).
|
|
27
27
|
- **Test Coverage Analysis**: Deep inspection of test coverage gaps.
|
|
28
28
|
- **Secret Detection**: Catch hardcoded credentials before they reach your repository.
|
|
29
29
|
- **Config Validation**: Ensure your CI/CD and infrastructure configuration files are sound.
|
|
30
|
-
- **PR Metrics**: Analyze pull request size, churn, and
|
|
30
|
+
- **PR Metrics & Velocity Tracking**: Analyze pull request size, churn, and time span metrics.
|
|
31
|
+
- **AI Reviewer**: Integrates with OpenAI to automatically find logic flaws and missing edge cases.
|
|
32
|
+
- **Image Optimization**: Detects large unoptimized assets (.png, .jpg, .jpeg) over 500KB.
|
|
33
|
+
- **Localization Sync**: Finds missing translation keys by comparing localization files against a base en.json.
|
|
31
34
|
|
|
32
35
|
## Installation
|
|
33
36
|
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Code Quality**: Intelligent linting and complexity analysis
|
|
7
|
+
- **Code Quality**: Intelligent linting and AST-based complexity analysis (cyclomatic complexity, long functions).
|
|
8
8
|
- **Test Coverage Analysis**: Deep inspection of test coverage gaps.
|
|
9
9
|
- **Secret Detection**: Catch hardcoded credentials before they reach your repository.
|
|
10
10
|
- **Config Validation**: Ensure your CI/CD and infrastructure configuration files are sound.
|
|
11
|
-
- **PR Metrics**: Analyze pull request size, churn, and
|
|
11
|
+
- **PR Metrics & Velocity Tracking**: Analyze pull request size, churn, and time span metrics.
|
|
12
|
+
- **AI Reviewer**: Integrates with OpenAI to automatically find logic flaws and missing edge cases.
|
|
13
|
+
- **Image Optimization**: Detects large unoptimized assets (.png, .jpg, .jpeg) over 500KB.
|
|
14
|
+
- **Localization Sync**: Finds missing translation keys by comparing localization files against a base en.json.
|
|
12
15
|
|
|
13
16
|
## Installation
|
|
14
17
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import urllib.request
|
|
4
|
+
from typing import List
|
|
5
|
+
from .scanner import Finding, git_changed_files, git_diff
|
|
6
|
+
|
|
7
|
+
def analyze() -> List[Finding]:
|
|
8
|
+
api_key = os.environ.get("CIFORGE_AI_KEY")
|
|
9
|
+
if not api_key:
|
|
10
|
+
return []
|
|
11
|
+
|
|
12
|
+
files = git_changed_files()
|
|
13
|
+
diffs = []
|
|
14
|
+
for f in files:
|
|
15
|
+
diff_text = git_diff(f)
|
|
16
|
+
if diff_text:
|
|
17
|
+
diffs.append(f"File: {f}\n{diff_text}")
|
|
18
|
+
|
|
19
|
+
if not diffs:
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
full_diff = "\n\n".join(diffs)
|
|
23
|
+
|
|
24
|
+
# We don't want to send huge diffs to API, but for this mock/simple implementation we do.
|
|
25
|
+
prompt = "Review the following code diff and identify logic flaws or missing edge cases. Provide a brief summary.\n\n" + full_diff
|
|
26
|
+
|
|
27
|
+
req_data = json.dumps({
|
|
28
|
+
"model": "gpt-3.5-turbo",
|
|
29
|
+
"messages": [
|
|
30
|
+
{"role": "system", "content": "You are a code reviewer."},
|
|
31
|
+
{"role": "user", "content": prompt}
|
|
32
|
+
]
|
|
33
|
+
}).encode("utf-8")
|
|
34
|
+
|
|
35
|
+
req = urllib.request.Request(
|
|
36
|
+
"https://api.openai.com/v1/chat/completions",
|
|
37
|
+
data=req_data,
|
|
38
|
+
headers={
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"Authorization": f"Bearer {api_key}"
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req) as response:
|
|
46
|
+
result = json.loads(response.read().decode("utf-8"))
|
|
47
|
+
summary = result["choices"][0]["message"]["content"]
|
|
48
|
+
return [Finding("AI_Review", 0, f"AI Reviewer: {summary}", "medium")]
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return [Finding("AI_Review", 0, f"AI Reviewer failed: {e}", "low")]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import List
|
|
3
|
+
from .scanner import Finding
|
|
4
|
+
|
|
5
|
+
def analyze() -> List[Finding]:
|
|
6
|
+
findings = []
|
|
7
|
+
# Analyze the repository files for large images
|
|
8
|
+
for root, _, files in os.walk('.'):
|
|
9
|
+
if '.git' in root:
|
|
10
|
+
continue
|
|
11
|
+
for file in files:
|
|
12
|
+
if file.lower().endswith(('.png', '.jpg', '.jpeg')):
|
|
13
|
+
file_path = os.path.join(root, file)
|
|
14
|
+
try:
|
|
15
|
+
size = os.path.getsize(file_path)
|
|
16
|
+
if size > 500 * 1024:
|
|
17
|
+
findings.append(Finding(
|
|
18
|
+
file=file_path,
|
|
19
|
+
line=0,
|
|
20
|
+
message=f"Unoptimized asset: {file} is {size / (1024 * 1024):.2f}MB. Compress it.",
|
|
21
|
+
severity="high"
|
|
22
|
+
))
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
return findings
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import sys
|
|
3
|
-
from . import scanner, code_quality, secrets, config_validator, coverage
|
|
3
|
+
from . import scanner, code_quality, secrets, config_validator, coverage, ai_reviewer, assets, l10n, metrics
|
|
4
4
|
|
|
5
5
|
SEVERITY_LEVELS = {'low': 0, 'medium': 1, 'high': 2, 'critical': 3}
|
|
6
6
|
|
|
@@ -19,6 +19,10 @@ def main():
|
|
|
19
19
|
all_findings.extend(config_validator.analyze(f, diff_text))
|
|
20
20
|
|
|
21
21
|
all_findings.extend(coverage.analyze())
|
|
22
|
+
all_findings.extend(ai_reviewer.analyze())
|
|
23
|
+
all_findings.extend(assets.analyze())
|
|
24
|
+
all_findings.extend(l10n.analyze())
|
|
25
|
+
all_findings.extend(metrics.analyze())
|
|
22
26
|
|
|
23
27
|
if not all_findings:
|
|
24
28
|
print("No issues found. Great job!")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import ast
|
|
3
|
+
import os
|
|
4
|
+
from typing import List
|
|
5
|
+
from .scanner import Finding, _extract_diff_sections, git_diff
|
|
6
|
+
|
|
7
|
+
def analyze(file_path: str, diff_text: str = None) -> List[Finding]:
|
|
8
|
+
if diff_text is None:
|
|
9
|
+
diff_text = git_diff(file_path)
|
|
10
|
+
|
|
11
|
+
findings = []
|
|
12
|
+
added_lines = _extract_diff_sections(diff_text)
|
|
13
|
+
|
|
14
|
+
contiguous_count = 0
|
|
15
|
+
contiguous_start_line = 0
|
|
16
|
+
|
|
17
|
+
for line_num, line_content in added_lines:
|
|
18
|
+
# Check for TODO / FIXME / HACK
|
|
19
|
+
match = re.search(r'\b(TODO|FIXME|HACK)\b', line_content)
|
|
20
|
+
if match:
|
|
21
|
+
findings.append(Finding(file_path, line_num, f"Found {match.group(1)}", "medium"))
|
|
22
|
+
|
|
23
|
+
# Check for console.log, print(), debugger
|
|
24
|
+
if re.search(r'\b(console\.log|print\s*\(|debugger\b)', line_content):
|
|
25
|
+
findings.append(Finding(file_path, line_num, "Found debug statement", "medium"))
|
|
26
|
+
|
|
27
|
+
# Very large function primitive check
|
|
28
|
+
if line_content.strip() == '':
|
|
29
|
+
contiguous_count = 0
|
|
30
|
+
else:
|
|
31
|
+
if contiguous_count == 0:
|
|
32
|
+
contiguous_start_line = line_num
|
|
33
|
+
contiguous_count += 1
|
|
34
|
+
|
|
35
|
+
if contiguous_count == 51:
|
|
36
|
+
findings.append(Finding(file_path, contiguous_start_line, "Very large function/block detected (>50 lines)", "low"))
|
|
37
|
+
|
|
38
|
+
# AST analysis for python files
|
|
39
|
+
if file_path.endswith('.py') and os.path.exists(file_path):
|
|
40
|
+
try:
|
|
41
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
42
|
+
tree = ast.parse(f.read(), filename=file_path)
|
|
43
|
+
|
|
44
|
+
for node in ast.walk(tree):
|
|
45
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
46
|
+
# Cyclomatic complexity
|
|
47
|
+
complexity = 1
|
|
48
|
+
for child in ast.walk(node):
|
|
49
|
+
if isinstance(child, (ast.If, ast.For, ast.While, ast.Try, ast.ExceptHandler, ast.With, ast.AsyncFor, ast.AsyncWith)):
|
|
50
|
+
complexity += 1
|
|
51
|
+
elif isinstance(child, ast.BoolOp):
|
|
52
|
+
complexity += len(child.values) - 1
|
|
53
|
+
|
|
54
|
+
if complexity > 10:
|
|
55
|
+
findings.append(Finding(file_path, getattr(node, 'lineno', 0), f"High cyclomatic complexity: {complexity} (>10)", "high"))
|
|
56
|
+
|
|
57
|
+
# Long function check
|
|
58
|
+
if hasattr(node, 'end_lineno') and hasattr(node, 'lineno'):
|
|
59
|
+
if node.end_lineno - node.lineno > 50:
|
|
60
|
+
findings.append(Finding(file_path, node.lineno, f"Long function: {node.name} is {node.end_lineno - node.lineno} lines (>50)", "high"))
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
return findings
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from typing import List
|
|
4
|
+
from .scanner import Finding
|
|
5
|
+
|
|
6
|
+
def analyze() -> List[Finding]:
|
|
7
|
+
findings = []
|
|
8
|
+
|
|
9
|
+
# Scan repo for localization files. Pick en.json as base.
|
|
10
|
+
for root, _, files in os.walk('.'):
|
|
11
|
+
if '.git' in root:
|
|
12
|
+
continue
|
|
13
|
+
|
|
14
|
+
if 'en.json' in files:
|
|
15
|
+
en_path = os.path.join(root, 'en.json')
|
|
16
|
+
try:
|
|
17
|
+
with open(en_path, 'r', encoding='utf-8') as f:
|
|
18
|
+
en_data = json.load(f)
|
|
19
|
+
en_keys = set(en_data.keys())
|
|
20
|
+
|
|
21
|
+
for file in files:
|
|
22
|
+
if file.endswith('.json') and file != 'en.json':
|
|
23
|
+
file_path = os.path.join(root, file)
|
|
24
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
25
|
+
data = json.load(f)
|
|
26
|
+
keys = set(data.keys())
|
|
27
|
+
|
|
28
|
+
missing = en_keys - keys
|
|
29
|
+
for key in missing:
|
|
30
|
+
findings.append(Finding(
|
|
31
|
+
file=file_path,
|
|
32
|
+
line=0,
|
|
33
|
+
message=f"Missing translation key: '{key}'",
|
|
34
|
+
severity="medium"
|
|
35
|
+
))
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
return findings
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from typing import List
|
|
3
|
+
from .scanner import Finding
|
|
4
|
+
|
|
5
|
+
def analyze() -> List[Finding]:
|
|
6
|
+
findings = []
|
|
7
|
+
try:
|
|
8
|
+
# Get commit timestamps
|
|
9
|
+
result = subprocess.run(["git", "log", "--format=%ct", "--reverse"], capture_output=True, text=True)
|
|
10
|
+
if result.returncode == 0:
|
|
11
|
+
lines = result.stdout.splitlines()
|
|
12
|
+
if len(lines) >= 2:
|
|
13
|
+
first_commit = int(lines[0])
|
|
14
|
+
last_commit = int(lines[-1])
|
|
15
|
+
diff_seconds = last_commit - first_commit
|
|
16
|
+
hours = diff_seconds / 3600
|
|
17
|
+
findings.append(Finding(
|
|
18
|
+
file="Git Metrics",
|
|
19
|
+
line=0,
|
|
20
|
+
message=f"Velocity Report: Branch active for {hours:.1f} hours over {len(lines)} commits.",
|
|
21
|
+
severity="low"
|
|
22
|
+
))
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
return findings
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ciforge-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A zero-dependency CI tool replacing multiple CI services.
|
|
5
5
|
Keywords: ci,github-actions,code-quality,coverage,linting
|
|
6
6
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -23,11 +23,14 @@ Dynamic: license-file
|
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
|
-
- **Code Quality**: Intelligent linting and complexity analysis
|
|
26
|
+
- **Code Quality**: Intelligent linting and AST-based complexity analysis (cyclomatic complexity, long functions).
|
|
27
27
|
- **Test Coverage Analysis**: Deep inspection of test coverage gaps.
|
|
28
28
|
- **Secret Detection**: Catch hardcoded credentials before they reach your repository.
|
|
29
29
|
- **Config Validation**: Ensure your CI/CD and infrastructure configuration files are sound.
|
|
30
|
-
- **PR Metrics**: Analyze pull request size, churn, and
|
|
30
|
+
- **PR Metrics & Velocity Tracking**: Analyze pull request size, churn, and time span metrics.
|
|
31
|
+
- **AI Reviewer**: Integrates with OpenAI to automatically find logic flaws and missing edge cases.
|
|
32
|
+
- **Image Optimization**: Detects large unoptimized assets (.png, .jpg, .jpeg) over 500KB.
|
|
33
|
+
- **Localization Sync**: Finds missing translation keys by comparing localization files against a base en.json.
|
|
31
34
|
|
|
32
35
|
## Installation
|
|
33
36
|
|
|
@@ -2,10 +2,14 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/ciforge/__init__.py
|
|
5
|
+
src/ciforge/ai_reviewer.py
|
|
6
|
+
src/ciforge/assets.py
|
|
5
7
|
src/ciforge/cli.py
|
|
6
8
|
src/ciforge/code_quality.py
|
|
7
9
|
src/ciforge/config_validator.py
|
|
8
10
|
src/ciforge/coverage.py
|
|
11
|
+
src/ciforge/l10n.py
|
|
12
|
+
src/ciforge/metrics.py
|
|
9
13
|
src/ciforge/scanner.py
|
|
10
14
|
src/ciforge/secrets.py
|
|
11
15
|
src/ciforge_cli.egg-info/PKG-INFO
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import patch, mock_open
|
|
3
|
+
|
|
4
|
+
from src.ciforge.scanner import Finding, _extract_diff_sections
|
|
5
|
+
from src.ciforge import code_quality, secrets, config_validator, coverage, ai_reviewer, assets, l10n, metrics
|
|
6
|
+
|
|
7
|
+
class TestCiforge(unittest.TestCase):
|
|
8
|
+
|
|
9
|
+
def test_extract_diff_sections(self):
|
|
10
|
+
diff = """--- a/file.py
|
|
11
|
+
+++ b/file.py
|
|
12
|
+
@@ -10,3 +10,4 @@
|
|
13
|
+
def func():
|
|
14
|
+
- pass
|
|
15
|
+
+ print('hello')
|
|
16
|
+
+ return True
|
|
17
|
+
"""
|
|
18
|
+
added = _extract_diff_sections(diff)
|
|
19
|
+
self.assertEqual(added, [(11, " print('hello')"), (12, " return True")])
|
|
20
|
+
|
|
21
|
+
def test_code_quality(self):
|
|
22
|
+
diff = "@@ -0,0 +1,55 @@\n+def large_func():\n" + "+ pass\n" * 50
|
|
23
|
+
findings = code_quality.analyze("file.py", diff)
|
|
24
|
+
self.assertTrue(any("Very large function" in f.message for f in findings))
|
|
25
|
+
self.assertTrue(any(f.severity == "low" for f in findings))
|
|
26
|
+
|
|
27
|
+
diff = "@@ -0,0 +1,2 @@\n+# TODO: fix this\n+console.log('test')\n"
|
|
28
|
+
findings = code_quality.analyze("file.js", diff)
|
|
29
|
+
self.assertEqual(len(findings), 2)
|
|
30
|
+
self.assertEqual(findings[0].message, "Found TODO")
|
|
31
|
+
self.assertEqual(findings[1].message, "Found debug statement")
|
|
32
|
+
|
|
33
|
+
def test_secrets(self):
|
|
34
|
+
fake_aws = "AKIA" + "IOSFODNN7EXAMPLE"
|
|
35
|
+
diff = f"@@ -0,0 +1,1 @@\n+my_var = '{fake_aws}'\n"
|
|
36
|
+
findings = secrets.analyze("config.py", diff)
|
|
37
|
+
self.assertEqual(len(findings), 1)
|
|
38
|
+
self.assertTrue("aws_key" in findings[0].message)
|
|
39
|
+
self.assertEqual(findings[0].severity, "critical")
|
|
40
|
+
|
|
41
|
+
fake_gh = "ghp_" + "123456789012345678901234567890123456"
|
|
42
|
+
diff2 = f"@@ -0,0 +1,1 @@\n+my_token_var = '{fake_gh}'\n"
|
|
43
|
+
findings2 = secrets.analyze("auth.py", diff2)
|
|
44
|
+
self.assertEqual(len(findings2), 1)
|
|
45
|
+
self.assertTrue("github_token" in findings2[0].message)
|
|
46
|
+
|
|
47
|
+
def test_config_validator_env(self):
|
|
48
|
+
diff = "@@ -0,0 +1,1 @@\n+SECRET=123\n"
|
|
49
|
+
findings = config_validator.analyze(".env", diff)
|
|
50
|
+
self.assertEqual(len(findings), 1)
|
|
51
|
+
self.assertEqual(findings[0].severity, "high")
|
|
52
|
+
|
|
53
|
+
findings2 = config_validator.analyze(".env.example", diff)
|
|
54
|
+
self.assertEqual(len(findings2), 0)
|
|
55
|
+
|
|
56
|
+
def test_config_validator_yaml(self):
|
|
57
|
+
diff = "@@ -0,0 +1,1 @@\n+\tkey: value\n"
|
|
58
|
+
findings = config_validator.analyze("config.yml", diff)
|
|
59
|
+
self.assertEqual(len(findings), 1)
|
|
60
|
+
self.assertTrue("tabs" in findings[0].message)
|
|
61
|
+
self.assertEqual(findings[0].severity, "medium")
|
|
62
|
+
|
|
63
|
+
@patch("os.path.exists", return_value=True)
|
|
64
|
+
def test_coverage(self, mock_exists):
|
|
65
|
+
with patch("builtins.open", mock_open(read_data='{"coverage": 75.0}')):
|
|
66
|
+
findings = coverage.analyze()
|
|
67
|
+
self.assertEqual(len(findings), 1)
|
|
68
|
+
self.assertTrue("Code coverage too low" in findings[0].message)
|
|
69
|
+
self.assertEqual(findings[0].severity, "medium")
|
|
70
|
+
|
|
71
|
+
@patch("os.environ.get", return_value="dummy_key")
|
|
72
|
+
@patch("src.ciforge.ai_reviewer.git_changed_files", return_value=["test.py"])
|
|
73
|
+
@patch("src.ciforge.ai_reviewer.git_diff", return_value="+print('test')")
|
|
74
|
+
@patch("urllib.request.urlopen")
|
|
75
|
+
def test_ai_reviewer(self, mock_urlopen, mock_git_diff, mock_git_files, mock_env):
|
|
76
|
+
from io import BytesIO
|
|
77
|
+
import json
|
|
78
|
+
mock_response = BytesIO(json.dumps({"choices": [{"message": {"content": "Looks good"}}]}).encode("utf-8"))
|
|
79
|
+
mock_urlopen.return_value.__enter__.return_value = mock_response
|
|
80
|
+
findings = ai_reviewer.analyze()
|
|
81
|
+
self.assertEqual(len(findings), 1)
|
|
82
|
+
self.assertTrue("Looks good" in findings[0].message)
|
|
83
|
+
|
|
84
|
+
@patch("os.walk")
|
|
85
|
+
@patch("os.path.getsize", return_value=600 * 1024)
|
|
86
|
+
def test_assets(self, mock_getsize, mock_walk):
|
|
87
|
+
mock_walk.return_value = [(".", [], ["large_image.png"])]
|
|
88
|
+
findings = assets.analyze()
|
|
89
|
+
self.assertEqual(len(findings), 1)
|
|
90
|
+
self.assertTrue("Unoptimized asset" in findings[0].message)
|
|
91
|
+
|
|
92
|
+
@patch("os.walk")
|
|
93
|
+
@patch("builtins.open", new_callable=mock_open)
|
|
94
|
+
def test_l10n(self, mock_open_file, mock_walk):
|
|
95
|
+
mock_walk.return_value = [(".", [], ["en.json", "fr.json"])]
|
|
96
|
+
def side_effect(filename, *args, **kwargs):
|
|
97
|
+
if "en.json" in filename:
|
|
98
|
+
return mock_open(read_data='{"hello": "world", "test": "1"}').return_value
|
|
99
|
+
else:
|
|
100
|
+
return mock_open(read_data='{"hello": "monde"}').return_value
|
|
101
|
+
mock_open_file.side_effect = side_effect
|
|
102
|
+
findings = l10n.analyze()
|
|
103
|
+
self.assertEqual(len(findings), 1)
|
|
104
|
+
self.assertTrue("Missing translation key: 'test'" in findings[0].message)
|
|
105
|
+
|
|
106
|
+
@patch("subprocess.run")
|
|
107
|
+
def test_metrics(self, mock_run):
|
|
108
|
+
mock_run.return_value.returncode = 0
|
|
109
|
+
mock_run.return_value.stdout = "1000\n2000\n5600"
|
|
110
|
+
findings = metrics.analyze()
|
|
111
|
+
self.assertEqual(len(findings), 1)
|
|
112
|
+
self.assertTrue("Velocity Report" in findings[0].message)
|
|
113
|
+
|
|
114
|
+
@patch("os.path.exists", return_value=True)
|
|
115
|
+
def test_code_quality_ast(self, mock_exists):
|
|
116
|
+
python_code = "def complex_func():\n" + "".join([f" if True:\n pass\n" for _ in range(11)])
|
|
117
|
+
with patch("builtins.open", mock_open(read_data=python_code)):
|
|
118
|
+
findings = code_quality.analyze("file.py", "+def complex_func():")
|
|
119
|
+
self.assertTrue(any("High cyclomatic complexity" in f.message for f in findings))
|
|
120
|
+
|
|
121
|
+
if __name__ == '__main__':
|
|
122
|
+
unittest.main()
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
from typing import List
|
|
3
|
-
from .scanner import Finding, _extract_diff_sections, git_diff
|
|
4
|
-
|
|
5
|
-
def analyze(file_path: str, diff_text: str = None) -> List[Finding]:
|
|
6
|
-
if diff_text is None:
|
|
7
|
-
diff_text = git_diff(file_path)
|
|
8
|
-
|
|
9
|
-
findings = []
|
|
10
|
-
added_lines = _extract_diff_sections(diff_text)
|
|
11
|
-
|
|
12
|
-
contiguous_count = 0
|
|
13
|
-
contiguous_start_line = 0
|
|
14
|
-
|
|
15
|
-
for line_num, line_content in added_lines:
|
|
16
|
-
# Check for TODO / FIXME / HACK
|
|
17
|
-
match = re.search(r'\b(TODO|FIXME|HACK)\b', line_content)
|
|
18
|
-
if match:
|
|
19
|
-
findings.append(Finding(file_path, line_num, f"Found {match.group(1)}", "medium"))
|
|
20
|
-
|
|
21
|
-
# Check for console.log, print(), debugger
|
|
22
|
-
if re.search(r'\b(console\.log|print\s*\(|debugger\b)', line_content):
|
|
23
|
-
findings.append(Finding(file_path, line_num, "Found debug statement", "medium"))
|
|
24
|
-
|
|
25
|
-
# Very large function primitive check
|
|
26
|
-
if line_content.strip() == '':
|
|
27
|
-
contiguous_count = 0
|
|
28
|
-
else:
|
|
29
|
-
if contiguous_count == 0:
|
|
30
|
-
contiguous_start_line = line_num
|
|
31
|
-
contiguous_count += 1
|
|
32
|
-
|
|
33
|
-
if contiguous_count == 51:
|
|
34
|
-
findings.append(Finding(file_path, contiguous_start_line, "Very large function/block detected (>50 lines)", "low"))
|
|
35
|
-
|
|
36
|
-
return findings
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from unittest.mock import patch, mock_open
|
|
3
|
-
|
|
4
|
-
from src.ciforge.scanner import Finding, _extract_diff_sections
|
|
5
|
-
from src.ciforge import code_quality, secrets, config_validator, coverage
|
|
6
|
-
|
|
7
|
-
class TestCiforge(unittest.TestCase):
|
|
8
|
-
|
|
9
|
-
def test_extract_diff_sections(self):
|
|
10
|
-
diff = """--- a/file.py
|
|
11
|
-
+++ b/file.py
|
|
12
|
-
@@ -10,3 +10,4 @@
|
|
13
|
-
def func():
|
|
14
|
-
- pass
|
|
15
|
-
+ print('hello')
|
|
16
|
-
+ return True
|
|
17
|
-
"""
|
|
18
|
-
added = _extract_diff_sections(diff)
|
|
19
|
-
self.assertEqual(added, [(11, " print('hello')"), (12, " return True")])
|
|
20
|
-
|
|
21
|
-
def test_code_quality(self):
|
|
22
|
-
diff = "@@ -0,0 +1,55 @@\n+def large_func():\n" + "+ pass\n" * 50
|
|
23
|
-
findings = code_quality.analyze("file.py", diff)
|
|
24
|
-
self.assertTrue(any("Very large function" in f.message for f in findings))
|
|
25
|
-
self.assertTrue(any(f.severity == "low" for f in findings))
|
|
26
|
-
|
|
27
|
-
diff = "@@ -0,0 +1,2 @@\n+# TODO: fix this\n+console.log('test')\n"
|
|
28
|
-
findings = code_quality.analyze("file.js", diff)
|
|
29
|
-
self.assertEqual(len(findings), 2)
|
|
30
|
-
self.assertEqual(findings[0].message, "Found TODO")
|
|
31
|
-
self.assertEqual(findings[1].message, "Found debug statement")
|
|
32
|
-
|
|
33
|
-
def test_secrets(self):
|
|
34
|
-
diff = "@@ -0,0 +1,1 @@\n+my_var = 'AKIAIOSFODNN7EXAMPLE'\n"
|
|
35
|
-
findings = secrets.analyze("config.py", diff)
|
|
36
|
-
self.assertEqual(len(findings), 1)
|
|
37
|
-
self.assertTrue("aws_key" in findings[0].message)
|
|
38
|
-
self.assertEqual(findings[0].severity, "critical")
|
|
39
|
-
|
|
40
|
-
diff2 = "@@ -0,0 +1,1 @@\n+my_token_var = 'ghp_123456789012345678901234567890123456'\n"
|
|
41
|
-
findings2 = secrets.analyze("auth.py", diff2)
|
|
42
|
-
self.assertEqual(len(findings2), 1)
|
|
43
|
-
self.assertTrue("github_token" in findings2[0].message)
|
|
44
|
-
|
|
45
|
-
def test_config_validator_env(self):
|
|
46
|
-
diff = "@@ -0,0 +1,1 @@\n+SECRET=123\n"
|
|
47
|
-
findings = config_validator.analyze(".env", diff)
|
|
48
|
-
self.assertEqual(len(findings), 1)
|
|
49
|
-
self.assertEqual(findings[0].severity, "high")
|
|
50
|
-
|
|
51
|
-
findings2 = config_validator.analyze(".env.example", diff)
|
|
52
|
-
self.assertEqual(len(findings2), 0)
|
|
53
|
-
|
|
54
|
-
def test_config_validator_yaml(self):
|
|
55
|
-
diff = "@@ -0,0 +1,1 @@\n+\tkey: value\n"
|
|
56
|
-
findings = config_validator.analyze("config.yml", diff)
|
|
57
|
-
self.assertEqual(len(findings), 1)
|
|
58
|
-
self.assertTrue("tabs" in findings[0].message)
|
|
59
|
-
self.assertEqual(findings[0].severity, "medium")
|
|
60
|
-
|
|
61
|
-
@patch("os.path.exists", return_value=True)
|
|
62
|
-
def test_coverage(self, mock_exists):
|
|
63
|
-
with patch("builtins.open", mock_open(read_data='{"coverage": 75.0}')):
|
|
64
|
-
findings = coverage.analyze()
|
|
65
|
-
self.assertEqual(len(findings), 1)
|
|
66
|
-
self.assertTrue("Code coverage too low" in findings[0].message)
|
|
67
|
-
self.assertEqual(findings[0].severity, "medium")
|
|
68
|
-
|
|
69
|
-
if __name__ == '__main__':
|
|
70
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|