traceclip 0.1.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.
- traceclip-0.1.0/.github/workflows/detector_tests.yml +34 -0
- traceclip-0.1.0/.github/workflows/performance_tests.yml +34 -0
- traceclip-0.1.0/.github/workflows/test_cross-os.yml +50 -0
- traceclip-0.1.0/LICENSE +21 -0
- traceclip-0.1.0/PKG-INFO +67 -0
- traceclip-0.1.0/README.md +42 -0
- traceclip-0.1.0/pyproject.toml +41 -0
- traceclip-0.1.0/src/traceclip/__init__.py +3 -0
- traceclip-0.1.0/src/traceclip/cli.py +170 -0
- traceclip-0.1.0/src/traceclip/detector.py +358 -0
- traceclip-0.1.0/src/traceclip/fixer.py +137 -0
- traceclip-0.1.0/tests/__init__.py +1 -0
- traceclip-0.1.0/tests/test_detector.py +129 -0
- traceclip-0.1.0/tests/test_fixtures/dirty_sample.py +38 -0
- traceclip-0.1.0/tests/test_performance.py +84 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Detector Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Run Detector Unit Tests (Python ${{ matrix.python-version }})
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.8", "3.10", "3.12"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout repository
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
cache: 'pip'
|
|
26
|
+
|
|
27
|
+
- name: Install package and dependencies
|
|
28
|
+
run: |
|
|
29
|
+
python -m pip install --upgrade pip
|
|
30
|
+
pip install -e .
|
|
31
|
+
|
|
32
|
+
- name: Run Detector Tests
|
|
33
|
+
run: |
|
|
34
|
+
python -m unittest tests/test_detector.py
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Performance Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Run Performance Stress Tests (Python ${{ matrix.python-version }})
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.8", "3.10", "3.12"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout repository
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
cache: 'pip'
|
|
26
|
+
|
|
27
|
+
- name: Install package and dependencies
|
|
28
|
+
run: |
|
|
29
|
+
python -m pip install --upgrade pip
|
|
30
|
+
pip install -e .
|
|
31
|
+
|
|
32
|
+
- name: Run Performance Tests
|
|
33
|
+
run: |
|
|
34
|
+
python -m unittest tests/test_performance.py
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: TraceClip CI/CD
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "main" ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ "main" ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Test on ${{ matrix.os }} with Python ${{ matrix.python-version }}
|
|
12
|
+
runs-on: ${{ matrix.os }}
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false # If Windows fails, let Ubuntu finish running so you can see all errors
|
|
15
|
+
matrix:
|
|
16
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
17
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout Code
|
|
21
|
+
uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
24
|
+
uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
cache: 'pip' # Caches dependencies to speed up the workflow
|
|
28
|
+
|
|
29
|
+
- name: Install Dependencies
|
|
30
|
+
run: |
|
|
31
|
+
python -m pip install --upgrade pip
|
|
32
|
+
pip install pytest pytest-cov
|
|
33
|
+
pip install -e .
|
|
34
|
+
|
|
35
|
+
# CRITICAL: Headless Linux servers don't have a UI or clipboard by default.
|
|
36
|
+
# We install xclip and a virtual display (xvfb) so pyperclip doesn't instantly crash.
|
|
37
|
+
- name: Setup Headless Display for Linux Clipboard
|
|
38
|
+
if: runner.os == 'Linux'
|
|
39
|
+
run: |
|
|
40
|
+
sudo apt-get update
|
|
41
|
+
sudo apt-get install -y xclip xvfb
|
|
42
|
+
|
|
43
|
+
- name: Run Pytest (Linux)
|
|
44
|
+
if: runner.os == 'Linux'
|
|
45
|
+
# We use xvfb-run to simulate a screen for the clipboard
|
|
46
|
+
run: xvfb-run pytest --cov=traceclip -v
|
|
47
|
+
|
|
48
|
+
- name: Run Pytest (Windows & macOS)
|
|
49
|
+
if: runner.os != 'Linux'
|
|
50
|
+
run: pytest --cov=traceclip -v
|
traceclip-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prakhar Bhardwaj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
traceclip-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: traceclip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A fast, local linting wrapper specifically designed for AI-generated code.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Prakhar3801b/Traceclip
|
|
6
|
+
Project-URL: Repository, https://github.com/Prakhar3801b/Traceclip
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/Prakhar3801b/Traceclip/issues
|
|
8
|
+
Author-email: Prakhar Bhardwaj <bhardwajprakhar02@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,chatgpt,code-quality,copilot,linter,sanitizer,vibecheck
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Requires-Dist: rich>=12.0.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Traceclip (vibecheck) 🧼
|
|
27
|
+
|
|
28
|
+
**Traceclip** is a fast, local code sanitizer and linter designed specifically to prune and inspect AI-generated code.
|
|
29
|
+
|
|
30
|
+
Rather than enforcing pedantic style formats like traditional linters, `vibecheck` hunts down common AI developer artifacts and "hallucinations":
|
|
31
|
+
- 🚫 **Unused Imports**: Bloat imported by AI but never referenced.
|
|
32
|
+
- 🪦 **Dead Functions**: Functions generated by the AI that are defined but never called or referenced.
|
|
33
|
+
- 🔑 **Placeholder Variables & Secrets**: Hardcoded templates like `API_KEY = "your_key_here"` or `URL = "https://example.com/api"`.
|
|
34
|
+
- 🕳️ **Stub Implementations**: Empty functions with only `pass`, `...`, or `raise NotImplementedError`.
|
|
35
|
+
|
|
36
|
+
It grades your script with a **Vibe Score** (out of 100). If your score drops too low, it fails!
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Inside the project directory, run:
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Check a script:
|
|
50
|
+
```bash
|
|
51
|
+
vibecheck my_script.py
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Check multiple files or directories:
|
|
55
|
+
```bash
|
|
56
|
+
vibecheck src/ tests/
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Automatically sanitize and fix issues:
|
|
60
|
+
```bash
|
|
61
|
+
vibecheck my_script.py --fix
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Enforce a minimum vibe score (ideal for CI/CD):
|
|
65
|
+
```bash
|
|
66
|
+
vibecheck my_script.py --min-score 80
|
|
67
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Traceclip (vibecheck) 🧼
|
|
2
|
+
|
|
3
|
+
**Traceclip** is a fast, local code sanitizer and linter designed specifically to prune and inspect AI-generated code.
|
|
4
|
+
|
|
5
|
+
Rather than enforcing pedantic style formats like traditional linters, `vibecheck` hunts down common AI developer artifacts and "hallucinations":
|
|
6
|
+
- 🚫 **Unused Imports**: Bloat imported by AI but never referenced.
|
|
7
|
+
- 🪦 **Dead Functions**: Functions generated by the AI that are defined but never called or referenced.
|
|
8
|
+
- 🔑 **Placeholder Variables & Secrets**: Hardcoded templates like `API_KEY = "your_key_here"` or `URL = "https://example.com/api"`.
|
|
9
|
+
- 🕳️ **Stub Implementations**: Empty functions with only `pass`, `...`, or `raise NotImplementedError`.
|
|
10
|
+
|
|
11
|
+
It grades your script with a **Vibe Score** (out of 100). If your score drops too low, it fails!
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Inside the project directory, run:
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Check a script:
|
|
25
|
+
```bash
|
|
26
|
+
vibecheck my_script.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Check multiple files or directories:
|
|
30
|
+
```bash
|
|
31
|
+
vibecheck src/ tests/
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Automatically sanitize and fix issues:
|
|
35
|
+
```bash
|
|
36
|
+
vibecheck my_script.py --fix
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Enforce a minimum vibe score (ideal for CI/CD):
|
|
40
|
+
```bash
|
|
41
|
+
vibecheck my_script.py --min-score 80
|
|
42
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "traceclip"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A fast, local linting wrapper specifically designed for AI-generated code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Prakhar Bhardwaj", email = "bhardwajprakhar02@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["linter", "ai", "code-quality", "sanitizer", "vibecheck", "copilot", "chatgpt"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.8",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"rich>=12.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Prakhar3801b/Traceclip"
|
|
34
|
+
Repository = "https://github.com/Prakhar3801b/Traceclip"
|
|
35
|
+
"Bug Tracker" = "https://github.com/Prakhar3801b/Traceclip/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
vibecheck = "traceclip.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/traceclip"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from traceclip.detector import detect_issues
|
|
13
|
+
from traceclip.fixer import fix_file
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
def find_py_files(paths: List[str]) -> List[Path]:
|
|
18
|
+
py_files = []
|
|
19
|
+
ignored_dirs = {".git", "venv", ".venv", "env", "__pycache__", ".pytest_cache", "build", "dist", ".egg-info"}
|
|
20
|
+
|
|
21
|
+
for path_str in paths:
|
|
22
|
+
path = Path(path_str)
|
|
23
|
+
if path.is_file():
|
|
24
|
+
if path.suffix == ".py":
|
|
25
|
+
py_files.append(path)
|
|
26
|
+
elif path.is_dir():
|
|
27
|
+
for root, dirs, files in os.walk(path):
|
|
28
|
+
# Prune ignored directories in-place to avoid traversing them
|
|
29
|
+
dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]
|
|
30
|
+
for file in files:
|
|
31
|
+
if file.endswith(".py"):
|
|
32
|
+
py_files.append(Path(root) / file)
|
|
33
|
+
return sorted(list(set(py_files)))
|
|
34
|
+
|
|
35
|
+
def format_score(score: int) -> Text:
|
|
36
|
+
if score >= 90:
|
|
37
|
+
return Text(f"{score}/100 (IMMACULATE VIBES)", style="bold green")
|
|
38
|
+
elif score >= 70:
|
|
39
|
+
return Text(f"{score}/100 (PASSABLE VIBES)", style="bold yellow")
|
|
40
|
+
else:
|
|
41
|
+
return Text(f"{score}/100 (BAD VIBES)", style="bold red")
|
|
42
|
+
|
|
43
|
+
def main():
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
description="Traceclip (vibecheck): The AI-Generated Code Sanitizer."
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"paths",
|
|
49
|
+
nargs="*",
|
|
50
|
+
default=["."],
|
|
51
|
+
help="Files or directories to scan (default: current directory)."
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--fix",
|
|
55
|
+
"-f",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Automatically sanitize and fix issues in-place."
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--min-score",
|
|
61
|
+
"-m",
|
|
62
|
+
type=int,
|
|
63
|
+
default=0,
|
|
64
|
+
help="Minimum allowed Vibe Score. Returns exit code 1 if any file scores lower."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
args = parser.parse_args()
|
|
68
|
+
py_files = find_py_files(args.paths)
|
|
69
|
+
|
|
70
|
+
if not py_files:
|
|
71
|
+
console.print("[bold red]Error:[/] No Python files found to scan.", style="red")
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
console.print(Panel(
|
|
75
|
+
"[bold cyan]Traceclip Vibecheck[/] - Linting wrapper for AI-generated code\n"
|
|
76
|
+
"[dim]Hunts down unused imports, dead functions, stub methods, and placeholder secrets.[/]",
|
|
77
|
+
border_style="cyan"
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
all_passed = True
|
|
81
|
+
total_issues = 0
|
|
82
|
+
total_files = len(py_files)
|
|
83
|
+
scores = []
|
|
84
|
+
|
|
85
|
+
for file_path in py_files:
|
|
86
|
+
try:
|
|
87
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
88
|
+
content = f.read()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
console.print(f"[bold red]Error reading file {file_path}:[/] {e}")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# Detect issues
|
|
94
|
+
issues = detect_issues(content, str(file_path))
|
|
95
|
+
|
|
96
|
+
# Calculate Vibe Score
|
|
97
|
+
deductions = sum(issue.weight for issue in issues)
|
|
98
|
+
score = max(0, 100 - deductions)
|
|
99
|
+
scores.append(score)
|
|
100
|
+
total_issues += len(issues)
|
|
101
|
+
|
|
102
|
+
# Print report
|
|
103
|
+
relative_path = os.path.relpath(file_path, os.getcwd())
|
|
104
|
+
console.print(f"\n[bold underline]{relative_path}[/]")
|
|
105
|
+
|
|
106
|
+
if not issues:
|
|
107
|
+
console.print(" [bold green]✓[/] Vibe check passed! Code is clean and tidy. (Score: 100/100)")
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Display issues in a table
|
|
111
|
+
table = Table(show_header=True, header_style="bold magenta", box=None, padding=(0, 2))
|
|
112
|
+
table.add_column("Line:Col", justify="right", style="dim")
|
|
113
|
+
table.add_column("Type", style="bold")
|
|
114
|
+
table.add_column("Message")
|
|
115
|
+
table.add_column("Context", style="italic dim")
|
|
116
|
+
|
|
117
|
+
for issue in issues:
|
|
118
|
+
sev_style = "bold red" if issue.severity == "error" else "yellow"
|
|
119
|
+
issue_type_formatted = f"[{sev_style}]{issue.issue_type.replace('_', ' ').title()}[/]"
|
|
120
|
+
table.add_row(
|
|
121
|
+
f"{issue.line}:{issue.col}",
|
|
122
|
+
issue_type_formatted,
|
|
123
|
+
issue.message,
|
|
124
|
+
issue.context
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
console.print(table)
|
|
128
|
+
console.print(f" [bold]Vibe Score:[/] ", end="")
|
|
129
|
+
console.print(format_score(score))
|
|
130
|
+
|
|
131
|
+
# Enforce min-score check
|
|
132
|
+
if score < args.min_score:
|
|
133
|
+
all_passed = False
|
|
134
|
+
|
|
135
|
+
# Run Auto-Fix if requested
|
|
136
|
+
if args.fix and len([i for i in issues if i.issue_type not in ("syntax_error", "wildcard_import")]) > 0:
|
|
137
|
+
console.print(f" [cyan]Applying autofixes to {relative_path}...[/]")
|
|
138
|
+
fixed_content = fix_file(content, str(file_path))
|
|
139
|
+
try:
|
|
140
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
141
|
+
f.write(fixed_content)
|
|
142
|
+
console.print(f" [bold green]✓[/] Sanitized and saved!")
|
|
143
|
+
# Recalculate score for summary print
|
|
144
|
+
fixed_issues = detect_issues(fixed_content, str(file_path))
|
|
145
|
+
fixed_deductions = sum(issue.weight for issue in fixed_issues)
|
|
146
|
+
fixed_score = max(0, 100 - fixed_deductions)
|
|
147
|
+
scores[-1] = fixed_score
|
|
148
|
+
console.print(f" [bold]New Vibe Score:[/] ", end="")
|
|
149
|
+
console.print(format_score(fixed_score))
|
|
150
|
+
except Exception as e:
|
|
151
|
+
console.print(f" [bold red]Failed to write fixes:[/] {e}")
|
|
152
|
+
|
|
153
|
+
# Summary Panel
|
|
154
|
+
avg_score = int(sum(scores) / len(scores)) if scores else 100
|
|
155
|
+
summary_style = "bold green" if avg_score >= 90 else "bold yellow" if avg_score >= 70 else "bold red"
|
|
156
|
+
|
|
157
|
+
console.print("\n" + "="*50)
|
|
158
|
+
console.print(f"[bold cyan]Summary Report:[/]")
|
|
159
|
+
console.print(f" Files scanned: {total_files}")
|
|
160
|
+
console.print(f" Total issues found: {total_issues}")
|
|
161
|
+
console.print(f" Average Vibe Score: ", end="")
|
|
162
|
+
console.print(format_score(avg_score))
|
|
163
|
+
console.print("="*50)
|
|
164
|
+
|
|
165
|
+
if not all_passed:
|
|
166
|
+
console.print("\n[bold red]✖ vibecheck failed:[/] One or more files scored below the minimum threshold of --min-score.", style="red")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Set, Dict, Any, Tuple
|
|
5
|
+
|
|
6
|
+
# Severity weights for scoring
|
|
7
|
+
WEIGHT_UNUSED_IMPORT = 5
|
|
8
|
+
WEIGHT_WILDCARD_IMPORT = 5
|
|
9
|
+
WEIGHT_DEAD_FUNCTION = 10
|
|
10
|
+
WEIGHT_STUB_FUNCTION = 15
|
|
11
|
+
WEIGHT_PLACEHOLDER_VAR = 20
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Issue:
|
|
15
|
+
file_path: str
|
|
16
|
+
line: int
|
|
17
|
+
col: int
|
|
18
|
+
issue_type: str
|
|
19
|
+
severity: str
|
|
20
|
+
message: str
|
|
21
|
+
weight: int
|
|
22
|
+
end_line: int = 0
|
|
23
|
+
context: str = ""
|
|
24
|
+
|
|
25
|
+
class CodeAnalyzer(ast.NodeVisitor):
|
|
26
|
+
def __init__(self, file_path: str, source_code: str):
|
|
27
|
+
self.file_path = file_path
|
|
28
|
+
self.source_code = source_code
|
|
29
|
+
self.source_lines = source_code.splitlines()
|
|
30
|
+
self.issues: List[Issue] = []
|
|
31
|
+
|
|
32
|
+
# Tracks imports: bound_name -> (line, col, import_node, alias_node)
|
|
33
|
+
self.imports: Dict[str, Tuple[int, int, ast.AST, ast.alias]] = {}
|
|
34
|
+
# Tracks occurrences of names used in Load context
|
|
35
|
+
self.name_loads: Set[str] = set()
|
|
36
|
+
# Tracks occurrences of attribute access
|
|
37
|
+
self.attribute_loads: Set[str] = set()
|
|
38
|
+
|
|
39
|
+
# Tracks defined functions: name -> {node, line, end_line, is_method}
|
|
40
|
+
self.functions: Dict[str, Dict[str, Any]] = {}
|
|
41
|
+
# Tracks function names referenced (loads or attributes)
|
|
42
|
+
self.referenced_names: Set[str] = set()
|
|
43
|
+
# Reference line numbers for each name: name -> list of lines where it was referenced
|
|
44
|
+
self.name_reference_lines: Dict[str, List[int]] = {}
|
|
45
|
+
# Tracks exported names in __all__
|
|
46
|
+
self.exported_names: Set[str] = set()
|
|
47
|
+
|
|
48
|
+
def add_name_reference(self, name: str, line: int):
|
|
49
|
+
self.referenced_names.add(name)
|
|
50
|
+
if name not in self.name_reference_lines:
|
|
51
|
+
self.name_reference_lines[name] = []
|
|
52
|
+
self.name_reference_lines[name].append(line)
|
|
53
|
+
|
|
54
|
+
def visit_Import(self, node: ast.Import):
|
|
55
|
+
for alias in node.names:
|
|
56
|
+
# import foo.bar as baz -> bound name is baz
|
|
57
|
+
# import foo.bar -> bound name is foo
|
|
58
|
+
bound_name = alias.asname if alias.asname else alias.name.split('.')[0]
|
|
59
|
+
self.imports[bound_name] = (node.lineno, node.col_offset, node, alias)
|
|
60
|
+
self.generic_visit(node)
|
|
61
|
+
|
|
62
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
63
|
+
for alias in node.names:
|
|
64
|
+
if alias.name == '*':
|
|
65
|
+
# Wildcard import detected
|
|
66
|
+
end_line = getattr(node, "end_lineno", node.lineno)
|
|
67
|
+
self.issues.append(Issue(
|
|
68
|
+
file_path=self.file_path,
|
|
69
|
+
line=node.lineno,
|
|
70
|
+
col=node.col_offset,
|
|
71
|
+
issue_type="wildcard_import",
|
|
72
|
+
severity="warning",
|
|
73
|
+
message=f"Wildcard import 'from {node.module} import *' pollutes namespace.",
|
|
74
|
+
weight=WEIGHT_WILDCARD_IMPORT,
|
|
75
|
+
end_line=end_line,
|
|
76
|
+
context=self.get_line_context(node.lineno)
|
|
77
|
+
))
|
|
78
|
+
continue
|
|
79
|
+
# from foo import bar as baz -> bound name is baz
|
|
80
|
+
# from foo import bar -> bound name is bar
|
|
81
|
+
bound_name = alias.asname if alias.asname else alias.name
|
|
82
|
+
self.imports[bound_name] = (node.lineno, node.col_offset, node, alias)
|
|
83
|
+
self.generic_visit(node)
|
|
84
|
+
|
|
85
|
+
def visit_Name(self, node: ast.Name):
|
|
86
|
+
if isinstance(node.ctx, ast.Load):
|
|
87
|
+
self.name_loads.add(node.id)
|
|
88
|
+
self.add_name_reference(node.id, node.lineno)
|
|
89
|
+
self.generic_visit(node)
|
|
90
|
+
|
|
91
|
+
def visit_Attribute(self, node: ast.Attribute):
|
|
92
|
+
# E.g. obj.method_name -> attribute access
|
|
93
|
+
self.attribute_loads.add(node.attr)
|
|
94
|
+
self.add_name_reference(node.attr, node.lineno)
|
|
95
|
+
self.generic_visit(node)
|
|
96
|
+
|
|
97
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
98
|
+
self.process_function(node)
|
|
99
|
+
|
|
100
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
101
|
+
self.process_function(node)
|
|
102
|
+
|
|
103
|
+
def process_function(self, node: Any):
|
|
104
|
+
# Skip dunder methods
|
|
105
|
+
if node.name.startswith("__") and node.name.endswith("__"):
|
|
106
|
+
self.generic_visit(node)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# Skip common entrypoints
|
|
110
|
+
if node.name == "main":
|
|
111
|
+
self.generic_visit(node)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Record function details
|
|
115
|
+
end_line = getattr(node, "end_lineno", node.lineno)
|
|
116
|
+
self.functions[node.name] = {
|
|
117
|
+
"node": node,
|
|
118
|
+
"line": node.lineno,
|
|
119
|
+
"end_line": end_line,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Check for stub implementations
|
|
123
|
+
self.check_stub_function(node)
|
|
124
|
+
|
|
125
|
+
self.generic_visit(node)
|
|
126
|
+
|
|
127
|
+
def check_stub_function(self, node: Any):
|
|
128
|
+
# A stub is a function with body containing only pass, Ellipsis, raise NotImplementedError
|
|
129
|
+
body = node.body
|
|
130
|
+
is_stub = False
|
|
131
|
+
reason = ""
|
|
132
|
+
|
|
133
|
+
if len(body) == 1:
|
|
134
|
+
stmt = body[0]
|
|
135
|
+
if isinstance(stmt, ast.Pass):
|
|
136
|
+
is_stub = True
|
|
137
|
+
reason = "contains only 'pass'"
|
|
138
|
+
elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and stmt.value.value is Ellipsis:
|
|
139
|
+
is_stub = True
|
|
140
|
+
reason = "contains only '...'"
|
|
141
|
+
elif isinstance(stmt, ast.Raise):
|
|
142
|
+
if isinstance(stmt.exc, ast.Name) and stmt.exc.id == "NotImplementedError":
|
|
143
|
+
is_stub = True
|
|
144
|
+
reason = "raises 'NotImplementedError'"
|
|
145
|
+
elif isinstance(stmt.exc, ast.Call) and isinstance(stmt.exc.func, ast.Name) and stmt.exc.func.id == "NotImplementedError":
|
|
146
|
+
is_stub = True
|
|
147
|
+
reason = "raises 'NotImplementedError'"
|
|
148
|
+
elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str):
|
|
149
|
+
# A function containing only a docstring
|
|
150
|
+
is_stub = True
|
|
151
|
+
reason = "contains only a docstring"
|
|
152
|
+
|
|
153
|
+
if is_stub:
|
|
154
|
+
self.issues.append(Issue(
|
|
155
|
+
file_path=self.file_path,
|
|
156
|
+
line=node.lineno,
|
|
157
|
+
col=node.col_offset,
|
|
158
|
+
issue_type="stub_function",
|
|
159
|
+
severity="warning",
|
|
160
|
+
message=f"Function '{node.name}' appears to be an empty stub ({reason}).",
|
|
161
|
+
weight=WEIGHT_STUB_FUNCTION,
|
|
162
|
+
end_line=getattr(node, "end_lineno", node.lineno),
|
|
163
|
+
context=self.get_line_context(node.lineno)
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
def visit_Assign(self, node: ast.Assign):
|
|
167
|
+
self.check_placeholder_assignment(node)
|
|
168
|
+
self.check_all_export(node)
|
|
169
|
+
self.generic_visit(node)
|
|
170
|
+
|
|
171
|
+
def check_all_export(self, node: ast.Assign):
|
|
172
|
+
for t in node.targets:
|
|
173
|
+
if isinstance(t, ast.Name) and t.id == "__all__":
|
|
174
|
+
if isinstance(node.value, (ast.List, ast.Tuple, ast.Set)):
|
|
175
|
+
for elt in node.value.elts:
|
|
176
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
177
|
+
self.exported_names.add(elt.value)
|
|
178
|
+
elif isinstance(elt, ast.Str):
|
|
179
|
+
self.exported_names.add(elt.s)
|
|
180
|
+
|
|
181
|
+
def visit_AnnAssign(self, node: ast.AnnAssign):
|
|
182
|
+
self.check_placeholder_assignment(node)
|
|
183
|
+
self.generic_visit(node)
|
|
184
|
+
|
|
185
|
+
def check_placeholder_assignment(self, node: Any):
|
|
186
|
+
# Extract string value if it's a constant
|
|
187
|
+
if not node.value or not isinstance(node.value, ast.Constant) or not isinstance(node.value.value, str):
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
val_str = node.value.value
|
|
191
|
+
targets = []
|
|
192
|
+
if isinstance(node, ast.Assign):
|
|
193
|
+
for t in node.targets:
|
|
194
|
+
if isinstance(t, ast.Name):
|
|
195
|
+
targets.append(t.id)
|
|
196
|
+
elif isinstance(t, ast.Attribute):
|
|
197
|
+
targets.append(t.attr)
|
|
198
|
+
elif isinstance(node, ast.AnnAssign):
|
|
199
|
+
if isinstance(node.target, ast.Name):
|
|
200
|
+
targets.append(node.target.id)
|
|
201
|
+
elif isinstance(node.target, ast.Attribute):
|
|
202
|
+
targets.append(node.target.attr)
|
|
203
|
+
|
|
204
|
+
placeholder_regexes = [
|
|
205
|
+
r"(?i)your_.*_here",
|
|
206
|
+
r"(?i)insert_.*",
|
|
207
|
+
r"(?i)enter_.*",
|
|
208
|
+
r"(?i)placeholder",
|
|
209
|
+
r"(?i)change[_-]?me",
|
|
210
|
+
r"(?i)todo",
|
|
211
|
+
r"(?i)<.*>",
|
|
212
|
+
r"(?i)\[.*\]",
|
|
213
|
+
r"(?i)your-.*",
|
|
214
|
+
r"^your_api_key$",
|
|
215
|
+
r"^your_token$",
|
|
216
|
+
r"^password123$",
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
for target in targets:
|
|
220
|
+
is_placeholder = False
|
|
221
|
+
# Check value against regexes
|
|
222
|
+
for r in placeholder_regexes:
|
|
223
|
+
if re.search(r, val_str):
|
|
224
|
+
is_placeholder = True
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
# Check key name + typical suspicious placeholder values
|
|
228
|
+
suspicious_keys = {"key", "token", "secret", "pass", "pwd", "credential", "url"}
|
|
229
|
+
any_suspicious_key = any(sk in target.lower() for sk in suspicious_keys)
|
|
230
|
+
|
|
231
|
+
if any_suspicious_key:
|
|
232
|
+
# If key is key/token/secret and value is very typical dummy text
|
|
233
|
+
dummy_texts = {"your", "insert", "placeholder", "enter", "todo", "example", "here", "change", "secret"}
|
|
234
|
+
if any(dt in val_str.lower() for dt in dummy_texts) or len(val_str) < 5 or val_str.strip() == "":
|
|
235
|
+
is_placeholder = True
|
|
236
|
+
|
|
237
|
+
if is_placeholder:
|
|
238
|
+
self.issues.append(Issue(
|
|
239
|
+
file_path=self.file_path,
|
|
240
|
+
line=node.lineno,
|
|
241
|
+
col=node.col_offset,
|
|
242
|
+
issue_type="placeholder_var",
|
|
243
|
+
severity="error",
|
|
244
|
+
message=f"Placeholder value '{val_str}' assigned to variable/attribute '{target}'.",
|
|
245
|
+
weight=WEIGHT_PLACEHOLDER_VAR,
|
|
246
|
+
end_line=getattr(node, "end_lineno", node.lineno),
|
|
247
|
+
context=self.get_line_context(node.lineno)
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
def get_line_context(self, lineno: int) -> str:
|
|
251
|
+
if 1 <= lineno <= len(self.source_lines):
|
|
252
|
+
return self.source_lines[lineno - 1].strip()
|
|
253
|
+
return ""
|
|
254
|
+
|
|
255
|
+
def analyze(self):
|
|
256
|
+
# Run visitor to collect all imports, functions, and references
|
|
257
|
+
self.visit(ast.parse(self.source_code))
|
|
258
|
+
|
|
259
|
+
# 1. Unused Imports check
|
|
260
|
+
# An import is unused if its bound name is never in name_loads (except inside imports themselves)
|
|
261
|
+
# Wait, if we import 'foo', but the only reference is the import statement itself.
|
|
262
|
+
# Since ast.alias / ast.Import nodes are visited, we should make sure that the name loads count references.
|
|
263
|
+
# Let's check which bound names have 0 references in the loads.
|
|
264
|
+
for bound_name, (line, col, node, alias) in self.imports.items():
|
|
265
|
+
if bound_name not in self.name_loads:
|
|
266
|
+
if bound_name not in self.exported_names:
|
|
267
|
+
name_desc = alias.name
|
|
268
|
+
if alias.asname:
|
|
269
|
+
name_desc = f"{alias.name} as {alias.asname}"
|
|
270
|
+
self.issues.append(Issue(
|
|
271
|
+
file_path=self.file_path,
|
|
272
|
+
line=line,
|
|
273
|
+
col=col,
|
|
274
|
+
issue_type="unused_import",
|
|
275
|
+
severity="warning",
|
|
276
|
+
message=f"Unused import '{name_desc}'.",
|
|
277
|
+
weight=WEIGHT_UNUSED_IMPORT,
|
|
278
|
+
end_line=getattr(node, "end_lineno", line),
|
|
279
|
+
context=self.get_line_context(line)
|
|
280
|
+
))
|
|
281
|
+
|
|
282
|
+
# 2. Dead Functions check
|
|
283
|
+
for func_name, info in self.functions.items():
|
|
284
|
+
ref_lines = self.name_reference_lines.get(func_name, [])
|
|
285
|
+
|
|
286
|
+
# An external reference is any reference outside the function's own body range
|
|
287
|
+
external_refs = [r_line for r_line in ref_lines if not (info["line"] <= r_line <= info["end_line"])]
|
|
288
|
+
|
|
289
|
+
if len(external_refs) == 0:
|
|
290
|
+
self.issues.append(Issue(
|
|
291
|
+
file_path=self.file_path,
|
|
292
|
+
line=info["line"],
|
|
293
|
+
col=info["node"].col_offset,
|
|
294
|
+
issue_type="dead_function",
|
|
295
|
+
severity="warning",
|
|
296
|
+
message=f"Function '{func_name}' is defined but never called or referenced externally.",
|
|
297
|
+
weight=WEIGHT_DEAD_FUNCTION,
|
|
298
|
+
end_line=info["end_line"],
|
|
299
|
+
context=self.get_line_context(info["line"])
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def scan_comments_for_placeholders(source_code: str, file_path: str) -> List[Issue]:
|
|
304
|
+
issues = []
|
|
305
|
+
lines = source_code.splitlines()
|
|
306
|
+
ai_comment_patterns = [
|
|
307
|
+
(r"(?i)#\s*todo:\s*(implement|replace|fill)", "AI placeholder comment: TODO implementation detail"),
|
|
308
|
+
(r"(?i)#\s*generated\s*by\s*(copilot|ai|chatgpt)", "AI leftover comment: 'Generated by AI'"),
|
|
309
|
+
(r"(?i)#\s*insert\s*.*\s*here", "AI placeholder comment: insert indicator"),
|
|
310
|
+
(r"(?i)#\s*change\s*me", "AI placeholder comment: 'change me'"),
|
|
311
|
+
]
|
|
312
|
+
for idx, line in enumerate(lines, 1):
|
|
313
|
+
# We only want to match actual comment lines or parts of lines that are comments
|
|
314
|
+
comment_match = re.search(r"#.*", line)
|
|
315
|
+
if comment_match:
|
|
316
|
+
comment_text = comment_match.group(0)
|
|
317
|
+
for pattern, message in ai_comment_patterns:
|
|
318
|
+
if re.search(pattern, comment_text):
|
|
319
|
+
issues.append(Issue(
|
|
320
|
+
file_path=file_path,
|
|
321
|
+
line=idx,
|
|
322
|
+
col=comment_match.start(),
|
|
323
|
+
issue_type="placeholder_var", # Treat comment placeholders similarly to variable placeholders
|
|
324
|
+
severity="warning",
|
|
325
|
+
message=message,
|
|
326
|
+
weight=5, # lighter weight for comments
|
|
327
|
+
context=line.strip()
|
|
328
|
+
))
|
|
329
|
+
return issues
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def detect_issues(source_code: str, file_path: str) -> List[Issue]:
|
|
333
|
+
# AST analysis
|
|
334
|
+
try:
|
|
335
|
+
analyzer = CodeAnalyzer(file_path, source_code)
|
|
336
|
+
analyzer.analyze()
|
|
337
|
+
issues = analyzer.issues
|
|
338
|
+
except SyntaxError as e:
|
|
339
|
+
# If there's a syntax error, we report it as a critical failure
|
|
340
|
+
issues = [Issue(
|
|
341
|
+
file_path=file_path,
|
|
342
|
+
line=e.lineno or 1,
|
|
343
|
+
col=e.offset or 1,
|
|
344
|
+
issue_type="syntax_error",
|
|
345
|
+
severity="error",
|
|
346
|
+
message=f"Syntax Error: {e.msg}",
|
|
347
|
+
weight=50,
|
|
348
|
+
context=e.text.strip() if e.text else ""
|
|
349
|
+
)]
|
|
350
|
+
return issues
|
|
351
|
+
|
|
352
|
+
# Comments analysis
|
|
353
|
+
comment_issues = scan_comments_for_placeholders(source_code, file_path)
|
|
354
|
+
issues.extend(comment_issues)
|
|
355
|
+
|
|
356
|
+
# Sort issues by line number
|
|
357
|
+
issues.sort(key=lambda x: x.line)
|
|
358
|
+
return issues
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from typing import List
|
|
4
|
+
from traceclip.detector import CodeAnalyzer, Issue, scan_comments_for_placeholders
|
|
5
|
+
|
|
6
|
+
def fix_file(source_code: str, file_path: str) -> str:
|
|
7
|
+
# 1. Detect issues using the same engine
|
|
8
|
+
try:
|
|
9
|
+
analyzer = CodeAnalyzer(file_path, source_code)
|
|
10
|
+
analyzer.analyze()
|
|
11
|
+
issues = analyzer.issues
|
|
12
|
+
# Exclude syntax errors and wildcard imports from being fixed automatically
|
|
13
|
+
issues = [i for i in issues if i.issue_type not in ("syntax_error", "wildcard_import")]
|
|
14
|
+
except Exception:
|
|
15
|
+
# If AST parsing fails, return source_code unmodified
|
|
16
|
+
return source_code
|
|
17
|
+
|
|
18
|
+
if not issues:
|
|
19
|
+
return source_code
|
|
20
|
+
|
|
21
|
+
# 2. Sort issues in descending order of line number to process bottom-up
|
|
22
|
+
# This prevents shifting lines of unprocessed issues above the current edit.
|
|
23
|
+
issues.sort(key=lambda x: (x.line, x.col), reverse=True)
|
|
24
|
+
|
|
25
|
+
lines = source_code.splitlines()
|
|
26
|
+
modified_lines = set()
|
|
27
|
+
needs_os_import = False
|
|
28
|
+
|
|
29
|
+
for issue in issues:
|
|
30
|
+
# Avoid double-modifying lines (e.g. if a function is both a stub and dead)
|
|
31
|
+
line_range = range(issue.line, issue.end_line + 1)
|
|
32
|
+
if any(l in modified_lines for l in line_range):
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Mark range as modified
|
|
36
|
+
for l in line_range:
|
|
37
|
+
modified_lines.add(l)
|
|
38
|
+
|
|
39
|
+
if issue.issue_type == "unused_import":
|
|
40
|
+
# Find the import node in the analyzer
|
|
41
|
+
# Let's search the imports dict for the one matching this line
|
|
42
|
+
matching_import_info = None
|
|
43
|
+
bound_name_to_remove = None
|
|
44
|
+
for name, info in analyzer.imports.items():
|
|
45
|
+
import_line, import_col, node, alias = info
|
|
46
|
+
if import_line == issue.line:
|
|
47
|
+
# Match by line number and column
|
|
48
|
+
matching_import_info = info
|
|
49
|
+
bound_name_to_remove = name
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if matching_import_info:
|
|
53
|
+
_, _, node, alias_node = matching_import_info
|
|
54
|
+
start_idx = node.lineno - 1
|
|
55
|
+
end_idx = getattr(node, "end_lineno", node.lineno) - 1
|
|
56
|
+
|
|
57
|
+
# Check if this import statement has multiple imported names
|
|
58
|
+
if len(node.names) > 1:
|
|
59
|
+
# Reconstruct separate imports for all names that are NOT unused
|
|
60
|
+
used_aliases = []
|
|
61
|
+
for a in node.names:
|
|
62
|
+
# Determine bound name for this alias
|
|
63
|
+
b_name = a.asname if a.asname else (a.name if isinstance(node, ast.ImportFrom) else a.name.split('.')[0])
|
|
64
|
+
# A name is used if it's not the one we are removing
|
|
65
|
+
if b_name != bound_name_to_remove and b_name in analyzer.name_loads:
|
|
66
|
+
used_aliases.append(a)
|
|
67
|
+
|
|
68
|
+
# Comment out the old import statement
|
|
69
|
+
for idx in range(start_idx, end_idx + 1):
|
|
70
|
+
lines[idx] = f"# vibecheck-removed: {lines[idx]}"
|
|
71
|
+
|
|
72
|
+
if used_aliases:
|
|
73
|
+
# Build the new import line
|
|
74
|
+
if isinstance(node, ast.Import):
|
|
75
|
+
new_import_parts = [f"{a.name} as {a.asname}" if a.asname else a.name for a in used_aliases]
|
|
76
|
+
new_import = "import " + ", ".join(new_import_parts)
|
|
77
|
+
else:
|
|
78
|
+
new_import_parts = [f"{a.name} as {a.asname}" if a.asname else a.name for a in used_aliases]
|
|
79
|
+
new_import = f"from {node.module} import " + ", ".join(new_import_parts)
|
|
80
|
+
|
|
81
|
+
# Append the new import statement right below the commented one
|
|
82
|
+
lines[end_idx] = lines[end_idx] + "\n" + new_import
|
|
83
|
+
else:
|
|
84
|
+
# Single name import: comment out the whole thing
|
|
85
|
+
for idx in range(start_idx, end_idx + 1):
|
|
86
|
+
lines[idx] = f"# vibecheck-removed: {lines[idx]}"
|
|
87
|
+
|
|
88
|
+
elif issue.issue_type in ("dead_function", "stub_function"):
|
|
89
|
+
start_idx = issue.line - 1
|
|
90
|
+
end_idx = issue.end_line - 1
|
|
91
|
+
prefix = "# vibecheck-removed: " if issue.issue_type == "dead_function" else "# vibecheck-stub-removed: "
|
|
92
|
+
for idx in range(start_idx, end_idx + 1):
|
|
93
|
+
lines[idx] = f"{prefix}{lines[idx]}"
|
|
94
|
+
|
|
95
|
+
elif issue.issue_type == "placeholder_var":
|
|
96
|
+
# For placeholder variables (API_KEY = "your_key"), replace with os.getenv
|
|
97
|
+
# Check if this is a comment placeholder or variable placeholder
|
|
98
|
+
# If it's a comment, we don't change the code (just keep it as is, or we can remove it. Let's just comment it out further or leave it)
|
|
99
|
+
if "comment:" in issue.message.lower():
|
|
100
|
+
# Comment placeholder, we can prefix the comment line with a warning or just leave it
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Variable placeholder: e.g. API_KEY = "your_key"
|
|
104
|
+
start_idx = issue.line - 1
|
|
105
|
+
end_idx = issue.end_line - 1
|
|
106
|
+
|
|
107
|
+
# Since AST is valid, we can parse this assignment line
|
|
108
|
+
# Let's extract the target and value
|
|
109
|
+
line_str = lines[start_idx]
|
|
110
|
+
if "=" in line_str:
|
|
111
|
+
parts = line_str.split("=", 1)
|
|
112
|
+
left_side = parts[0].rstrip()
|
|
113
|
+
# Find the target name
|
|
114
|
+
# Let's guess the target variable name: we can get it from the issue message or parse it
|
|
115
|
+
# E.g. "Placeholder value 'your_key' assigned to variable/attribute 'API_KEY'."
|
|
116
|
+
# We can extract the target name from the message or left_side
|
|
117
|
+
target_name = left_side.strip().split()[-1] # Simple guess
|
|
118
|
+
# Clean up characters like colon for typed annotations
|
|
119
|
+
if ":" in target_name:
|
|
120
|
+
target_name = target_name.split(":")[0].strip()
|
|
121
|
+
|
|
122
|
+
# Extract the original string literal value
|
|
123
|
+
# We know the issue.message contains "Placeholder value 'val' assigned to..."
|
|
124
|
+
# Let's extract 'val' from the message using regex
|
|
125
|
+
val_match = re.search(r"Placeholder value '(.*)' assigned to", issue.message)
|
|
126
|
+
val_str = val_match.group(1) if val_match else "your_key_here"
|
|
127
|
+
|
|
128
|
+
# Replace with os.getenv("TARGET_NAME", "default_val")
|
|
129
|
+
new_assignment = f"{left_side} = os.getenv({target_name!r}, {val_str!r})"
|
|
130
|
+
lines[start_idx] = f"# vibecheck-placeholder-fixed:\n# {line_str}\n{new_assignment}"
|
|
131
|
+
needs_os_import = True
|
|
132
|
+
|
|
133
|
+
# 3. Add import os if needed and not already imported and kept
|
|
134
|
+
if needs_os_import and ("os" not in analyzer.imports or "os" not in analyzer.name_loads):
|
|
135
|
+
lines.insert(0, "import os")
|
|
136
|
+
|
|
137
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Traceclip test package
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from traceclip.detector import detect_issues
|
|
3
|
+
from traceclip.fixer import fix_file
|
|
4
|
+
|
|
5
|
+
class TestTraceclip(unittest.TestCase):
|
|
6
|
+
def test_unused_imports(self):
|
|
7
|
+
code = """import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections import defaultdict, Counter
|
|
10
|
+
import math as m
|
|
11
|
+
|
|
12
|
+
d = defaultdict(int)
|
|
13
|
+
"""
|
|
14
|
+
issues = detect_issues(code, "test.py")
|
|
15
|
+
types = [i.issue_type for i in issues]
|
|
16
|
+
messages = [i.message for i in issues]
|
|
17
|
+
|
|
18
|
+
self.assertIn("unused_import", types)
|
|
19
|
+
# os, sys, Counter, m are unused
|
|
20
|
+
unused_names = [m for m in messages if "Unused import" in m]
|
|
21
|
+
self.assertEqual(len(unused_names), 4)
|
|
22
|
+
self.assertTrue(any("os" in m for m in unused_names))
|
|
23
|
+
self.assertTrue(any("sys" in m for m in unused_names))
|
|
24
|
+
self.assertTrue(any("Counter" in m for m in unused_names))
|
|
25
|
+
self.assertTrue(any("math as m" in m for m in unused_names))
|
|
26
|
+
|
|
27
|
+
def test_wildcard_import(self):
|
|
28
|
+
code = "from math import *"
|
|
29
|
+
issues = detect_issues(code, "test.py")
|
|
30
|
+
types = [i.issue_type for i in issues]
|
|
31
|
+
self.assertIn("wildcard_import", types)
|
|
32
|
+
|
|
33
|
+
def test_dead_functions(self):
|
|
34
|
+
code = """def dead_func(x):
|
|
35
|
+
return x * 2
|
|
36
|
+
|
|
37
|
+
def recursive_dead_func(n):
|
|
38
|
+
if n <= 1:
|
|
39
|
+
return 1
|
|
40
|
+
return n * recursive_dead_func(n-1)
|
|
41
|
+
|
|
42
|
+
def active_func():
|
|
43
|
+
return 42
|
|
44
|
+
|
|
45
|
+
print(active_func())
|
|
46
|
+
"""
|
|
47
|
+
issues = detect_issues(code, "test.py")
|
|
48
|
+
dead_funcs = [i.message for i in issues if i.issue_type == "dead_function"]
|
|
49
|
+
self.assertEqual(len(dead_funcs), 2)
|
|
50
|
+
self.assertTrue(any("dead_func" in m for m in dead_funcs))
|
|
51
|
+
self.assertTrue(any("recursive_dead_func" in m for m in dead_funcs))
|
|
52
|
+
self.assertFalse(any("active_func" in m for m in dead_funcs))
|
|
53
|
+
|
|
54
|
+
def test_placeholder_variables(self):
|
|
55
|
+
code = """API_KEY = "your_api_key_here"
|
|
56
|
+
PASSWORD = "insert_password_here"
|
|
57
|
+
token = "enter_token_here"
|
|
58
|
+
my_val = "normal value"
|
|
59
|
+
"""
|
|
60
|
+
issues = detect_issues(code, "test.py")
|
|
61
|
+
placeholders = [i.message for i in issues if i.issue_type == "placeholder_var"]
|
|
62
|
+
self.assertEqual(len(placeholders), 3)
|
|
63
|
+
self.assertTrue(any("API_KEY" in m for m in placeholders))
|
|
64
|
+
self.assertTrue(any("PASSWORD" in m for m in placeholders))
|
|
65
|
+
self.assertTrue(any("token" in m for m in placeholders))
|
|
66
|
+
self.assertFalse(any("my_val" in m for m in placeholders))
|
|
67
|
+
|
|
68
|
+
def test_stub_functions(self):
|
|
69
|
+
code = """def stub1():
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def stub2():
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def stub3():
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def active():
|
|
79
|
+
print("hello")
|
|
80
|
+
"""
|
|
81
|
+
issues = detect_issues(code, "test.py")
|
|
82
|
+
stubs = [i.message for i in issues if i.issue_type == "stub_function"]
|
|
83
|
+
self.assertEqual(len(stubs), 3)
|
|
84
|
+
self.assertTrue(any("stub1" in m for m in stubs))
|
|
85
|
+
self.assertTrue(any("stub2" in m for m in stubs))
|
|
86
|
+
self.assertTrue(any("stub3" in m for m in stubs))
|
|
87
|
+
self.assertFalse(any("active" in m for m in stubs))
|
|
88
|
+
|
|
89
|
+
def test_comment_scanning(self):
|
|
90
|
+
code = """# generated by copilot
|
|
91
|
+
# TODO: implement this
|
|
92
|
+
# normal comment
|
|
93
|
+
"""
|
|
94
|
+
issues = detect_issues(code, "test.py")
|
|
95
|
+
comments = [i.message for i in issues if i.issue_type == "placeholder_var" and "comment:" in i.message.lower()]
|
|
96
|
+
self.assertEqual(len(comments), 2)
|
|
97
|
+
|
|
98
|
+
def test_fix_unused_imports(self):
|
|
99
|
+
code = """import sys
|
|
100
|
+
from collections import defaultdict, Counter
|
|
101
|
+
|
|
102
|
+
d = defaultdict(int)
|
|
103
|
+
"""
|
|
104
|
+
fixed = fix_file(code, "test.py")
|
|
105
|
+
self.assertIn("# vibecheck-removed: import sys", fixed)
|
|
106
|
+
self.assertIn("# vibecheck-removed: from collections import defaultdict, Counter", fixed)
|
|
107
|
+
self.assertIn("from collections import defaultdict", fixed)
|
|
108
|
+
self.assertNotIn("Counter", fixed.splitlines()[-1]) # Counter is removed in the recreated import
|
|
109
|
+
|
|
110
|
+
def test_fix_dead_functions(self):
|
|
111
|
+
code = """def dead_func():
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
print("hello")
|
|
115
|
+
"""
|
|
116
|
+
fixed = fix_file(code, "test.py")
|
|
117
|
+
self.assertIn("# vibecheck-removed: def dead_func():", fixed)
|
|
118
|
+
self.assertIn("# vibecheck-removed: return 1", fixed)
|
|
119
|
+
|
|
120
|
+
def test_fix_placeholder_variables(self):
|
|
121
|
+
code = """API_KEY = "your_api_key_here"
|
|
122
|
+
"""
|
|
123
|
+
# API_KEY is a placeholder, should be replaced by os.getenv, and import os added
|
|
124
|
+
fixed = fix_file(code, "test.py")
|
|
125
|
+
self.assertIn("import os", fixed)
|
|
126
|
+
self.assertIn("API_KEY = os.getenv('API_KEY', 'your_api_key_here')", fixed)
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
unittest.main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Generated by ChatGPT
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
from collections import defaultdict, Counter
|
|
6
|
+
from math import *
|
|
7
|
+
|
|
8
|
+
API_KEY = "your_api_key_here"
|
|
9
|
+
PORT = 8080
|
|
10
|
+
|
|
11
|
+
def dead_utility_func(x):
|
|
12
|
+
# This is a dead function defined by AI but never called
|
|
13
|
+
return x * 2
|
|
14
|
+
|
|
15
|
+
def active_func(data):
|
|
16
|
+
# This is used and references defaultdict
|
|
17
|
+
d = defaultdict(int)
|
|
18
|
+
for item in data:
|
|
19
|
+
d[item] += 1
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
def recursive_dead_func(n):
|
|
23
|
+
if n <= 1:
|
|
24
|
+
return 1
|
|
25
|
+
# Only references itself (recursive)
|
|
26
|
+
return n * recursive_dead_func(n - 1)
|
|
27
|
+
|
|
28
|
+
def empty_stub_func():
|
|
29
|
+
# TODO: implement this later
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def raise_stub_func():
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
test_data = [1, 2, 2, 3]
|
|
37
|
+
result = active_func(test_data)
|
|
38
|
+
print(f"Result: {result}")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import time
|
|
3
|
+
from traceclip.detector import detect_issues
|
|
4
|
+
from traceclip.fixer import fix_file
|
|
5
|
+
|
|
6
|
+
class TestPerformance(unittest.TestCase):
|
|
7
|
+
def test_large_file_performance(self):
|
|
8
|
+
lines = []
|
|
9
|
+
|
|
10
|
+
# 1. Add 100 unused imports
|
|
11
|
+
for i in range(100):
|
|
12
|
+
lines.append(f"import unused_module_{i}")
|
|
13
|
+
|
|
14
|
+
# Add used imports
|
|
15
|
+
lines.append("import os")
|
|
16
|
+
lines.append("from collections import defaultdict")
|
|
17
|
+
|
|
18
|
+
# 2. Add 200 placeholder variables (100 API keys, 100 DB URLs)
|
|
19
|
+
for i in range(100):
|
|
20
|
+
lines.append(f"API_KEY_{i} = 'your_api_key_here_{i}'")
|
|
21
|
+
lines.append(f"DB_URL_{i} = 'insert_db_url_here'")
|
|
22
|
+
|
|
23
|
+
# 3. Add 100 stub functions (which will also be dead)
|
|
24
|
+
for i in range(100):
|
|
25
|
+
lines.append(f"def stub_function_{i}():")
|
|
26
|
+
lines.append(" pass")
|
|
27
|
+
|
|
28
|
+
# 4. Add 250 active functions (chained calls)
|
|
29
|
+
for i in range(250):
|
|
30
|
+
lines.append(f"def active_function_{i}(x):")
|
|
31
|
+
if i == 0:
|
|
32
|
+
lines.append(" return x + 1")
|
|
33
|
+
else:
|
|
34
|
+
lines.append(f" return active_function_{i-1}(x) + 1")
|
|
35
|
+
|
|
36
|
+
# 5. Add 250 dead functions
|
|
37
|
+
for i in range(250):
|
|
38
|
+
lines.append(f"def dead_function_{i}(x):")
|
|
39
|
+
lines.append(" return x * 2")
|
|
40
|
+
|
|
41
|
+
# 6. Entrypoint using active functions
|
|
42
|
+
lines.append("if __name__ == '__main__':")
|
|
43
|
+
lines.append(" d = defaultdict(int)")
|
|
44
|
+
lines.append(" print(active_function_249(10))")
|
|
45
|
+
|
|
46
|
+
large_code = "\n".join(lines)
|
|
47
|
+
line_count = len(lines)
|
|
48
|
+
|
|
49
|
+
# Measure detection speed
|
|
50
|
+
start_detect = time.perf_counter()
|
|
51
|
+
issues = detect_issues(large_code, "large_file.py")
|
|
52
|
+
detect_duration = time.perf_counter() - start_detect
|
|
53
|
+
|
|
54
|
+
# Measure fixing speed
|
|
55
|
+
start_fix = time.perf_counter()
|
|
56
|
+
fixed_code = fix_file(large_code, "large_file.py")
|
|
57
|
+
fix_duration = time.perf_counter() - start_fix
|
|
58
|
+
|
|
59
|
+
# Print stats
|
|
60
|
+
print(f"\n[Stress Test] Generated codebase: {line_count} lines.")
|
|
61
|
+
print(f" - Detected {len(issues)} issues in {detect_duration:.4f} seconds.")
|
|
62
|
+
print(f" - Fixed codebase in {fix_duration:.4f} seconds.")
|
|
63
|
+
|
|
64
|
+
# Performance assertions (sub-second threshold)
|
|
65
|
+
self.assertLess(detect_duration, 1.0, f"Detection took too long: {detect_duration:.4f}s")
|
|
66
|
+
self.assertLess(fix_duration, 1.5, f"Fixing took too long: {fix_duration:.4f}s")
|
|
67
|
+
|
|
68
|
+
# Correctness checks: we expect:
|
|
69
|
+
# - 100 unused imports
|
|
70
|
+
# - 200 placeholder variables
|
|
71
|
+
# - 100 stub functions
|
|
72
|
+
# - 250 dead functions
|
|
73
|
+
# - 100 stub functions (which are also dead functions)
|
|
74
|
+
# Total issues should be at least 750
|
|
75
|
+
self.assertGreaterEqual(len(issues), 750)
|
|
76
|
+
|
|
77
|
+
# Syntax check: verify the fixed codebase compiles successfully as valid Python
|
|
78
|
+
try:
|
|
79
|
+
compile(fixed_code, "large_file.py", "exec")
|
|
80
|
+
except SyntaxError as e:
|
|
81
|
+
self.fail(f"Autofixer produced syntactically invalid Python code: {e}")
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
unittest.main()
|