ghostbuster-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.
Files changed (43) hide show
  1. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/.github/workflows/ci.yml +8 -30
  2. ghostbuster_cli-0.2.0/.github/workflows/publish.yml +35 -0
  3. ghostbuster_cli-0.2.0/.pre-commit-hooks.yaml +8 -0
  4. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/CHANGELOG.md +10 -0
  5. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/PKG-INFO +22 -4
  6. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/README.md +15 -3
  7. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/pyproject.toml +11 -3
  8. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/__init__.py +1 -1
  9. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/bust.py +19 -7
  10. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/config.py +12 -10
  11. ghostbuster_cli-0.2.0/src/ghostbuster/fixers/__init__.py +29 -0
  12. ghostbuster_cli-0.2.0/src/ghostbuster/fixers/env_fixer.py +112 -0
  13. ghostbuster_cli-0.2.0/src/ghostbuster/fixers/gitignore_fixer.py +101 -0
  14. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_cli.py +1 -1
  15. ghostbuster_cli-0.2.0/tests/test_fixers.py +171 -0
  16. ghostbuster_cli-0.1.0/src/ghostbuster/fixers/__init__.py +0 -1
  17. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/.gitignore +0 -0
  18. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/CONTRIBUTING.md +0 -0
  19. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/LICENSE +0 -0
  20. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/assets/npm_package_icon.png +0 -0
  21. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/assets/readme_header.png +0 -0
  22. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/assets/terminal_icon.png +0 -0
  23. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/__main__.py +0 -0
  24. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/__init__.py +0 -0
  25. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/app.py +0 -0
  26. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/display.py +0 -0
  27. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/scan.py +0 -0
  28. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/__init__.py +0 -0
  29. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/dead_imports.py +0 -0
  30. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/models.py +0 -0
  31. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/orphan_files.py +0 -0
  32. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/phantom_env.py +0 -0
  33. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/scanner.py +0 -0
  34. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/scoring.py +0 -0
  35. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/zombie_code.py +0 -0
  36. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/fixers/import_fixer.py +0 -0
  37. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/__init__.py +0 -0
  38. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/conftest.py +0 -0
  39. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_dead_imports.py +0 -0
  40. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_orphan_files.py +0 -0
  41. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_phantom_env.py +0 -0
  42. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_scoring.py +0 -0
  43. {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_zombie_code.py +0 -0
@@ -11,7 +11,7 @@ permissions:
11
11
 
12
12
  jobs:
13
13
  lint:
14
- name: Lint
14
+ name: Lint & Type Check
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
17
  - uses: actions/checkout@v4
@@ -24,6 +24,7 @@ jobs:
24
24
  - name: Install dependencies
25
25
  run: |
26
26
  python -m pip install --upgrade pip
27
+ pip install -e .
27
28
  pip install ruff mypy
28
29
 
29
30
  - name: Ruff check
@@ -33,12 +34,10 @@ jobs:
33
34
  run: ruff format --check src/ tests/
34
35
 
35
36
  - name: Mypy type check
36
- run: |
37
- pip install -e ".[dev]" 2>/dev/null || pip install -e .
38
- mypy src/
37
+ run: mypy src/
39
38
 
40
39
  test:
41
- name: Test (Python ${{ matrix.python-version }})
40
+ name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }})
42
41
  runs-on: ${{ matrix.os }}
43
42
  strategy:
44
43
  fail-fast: false
@@ -69,7 +68,7 @@ jobs:
69
68
  run: pytest tests/ -v --tb=short
70
69
 
71
70
  build:
72
- name: Build check
71
+ name: Build Check
73
72
  runs-on: ubuntu-latest
74
73
  steps:
75
74
  - uses: actions/checkout@v4
@@ -80,33 +79,12 @@ jobs:
80
79
  python-version: "3.12"
81
80
 
82
81
  - name: Install build tools
83
- run: python -m pip install --upgrade pip build twine
82
+ run: |
83
+ python -m pip install --upgrade pip
84
+ pip install build twine
84
85
 
85
86
  - name: Build package
86
87
  run: python -m build
87
88
 
88
89
  - name: Check package
89
90
  run: twine check dist/*
90
-
91
- - name: Upload artifacts
92
- uses: actions/upload-artifact@v4
93
- with:
94
- name: dist
95
- path: dist/
96
-
97
- # Uncomment when ready to publish to PyPI
98
- # publish:
99
- # name: Publish to PyPI
100
- # needs: [lint, test, build]
101
- # runs-on: ubuntu-latest
102
- # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
103
- # environment: pypi
104
- # permissions:
105
- # id-token: write # Required for trusted publishing
106
- # steps:
107
- # - uses: actions/download-artifact@v4
108
- # with:
109
- # name: dist
110
- # path: dist/
111
- # - name: Publish to PyPI
112
- # uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ push:
7
+ tags:
8
+ - "v*"
9
+
10
+ jobs:
11
+ pypi-publish:
12
+ name: Upload package to PyPI
13
+ runs-on: ubuntu-latest
14
+ environment:
15
+ name: pypi
16
+ url: https://pypi.org/p/ghostbuster-cli
17
+ permissions:
18
+ id-token: write # Mandatory for Trusted Publishing (OIDC)
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Set up Python
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: "3.12"
27
+
28
+ - name: Install build
29
+ run: python -m pip install --upgrade pip build
30
+
31
+ - name: Build package
32
+ run: python -m build
33
+
34
+ - name: Publish package distributions to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ - id: ghostbuster-scan
2
+ name: ghostbuster scan
3
+ description: Find and bust the ghosts haunting your codebase
4
+ entry: ghostbuster scan
5
+ language: python
6
+ types: [python]
7
+ pass_filenames: false
8
+ always_run: true
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-08-29
11
+
12
+ ### Added
13
+
14
+ - **Pre-commit Hook Integration**: added `.pre-commit-hooks.yaml` for automated pre-commit scanning (`ghostbuster-scan`).
15
+ - **Auto-Fix for Orphan Files**: `ghostbuster bust --confirm` now automatically appends missing folders and files to `.gitignore`.
16
+ - **Auto-Fix for Phantom Env Vars**: `ghostbuster bust --confirm` now automatically appends missing environment variable stubs to `.env.example`.
17
+ - **Poetry Dependencies Support**: Dead Import Scanner now automatically parses `[tool.poetry.dependencies]` and `[tool.poetry.group.*.dependencies]`.
18
+ - Comprehensive test suite for all auto-fixers (`GitignoreFixer`, `EnvFixer`, `ImportFixer`).
19
+
10
20
  ## [0.1.0] - 2026-08-29
11
21
 
12
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostbuster-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Find and bust the ghosts haunting your codebase - unused deps, dead code, orphan files, and phantom env vars.
5
5
  Project-URL: Homepage, https://github.com/adewanggar/ghostbuster-cli
6
6
  Project-URL: Documentation, https://github.com/adewanggar/ghostbuster-cli#readme
@@ -28,6 +28,12 @@ Requires-Python: >=3.10
28
28
  Requires-Dist: rich>=13.0.0
29
29
  Requires-Dist: tomli>=2.0.0; python_version < '3.11'
30
30
  Requires-Dist: typer>=0.9.0
31
+ Provides-Extra: dev
32
+ Requires-Dist: build>=1.0.0; extra == 'dev'
33
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
35
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
36
+ Requires-Dist: twine>=5.0.0; extra == 'dev'
31
37
  Description-Content-Type: text/markdown
32
38
 
33
39
  <div align="center">
@@ -104,9 +110,9 @@ ghostbuster bust --confirm
104
110
  ```
105
111
 
106
112
  Currently auto-fixes:
107
- - Removes unused import statements
108
- - Suggests `.gitignore` additions for orphan files
109
- - Suggests `.env` additions for phantom env vars
113
+ - Removes unused import statements from Python files
114
+ - Appends missing orphan files and directories to `.gitignore`
115
+ - Appends missing environment variable stubs (`KEY=`) to `.env.example`
110
116
 
111
117
  ## Usage
112
118
 
@@ -146,6 +152,18 @@ ghostbuster scan --format json
146
152
  ghostbuster scan --format markdown
147
153
  ```
148
154
 
155
+ ### Pre-commit Hook Integration
156
+
157
+ Add Ghostbuster to your `.pre-commit-config.yaml`:
158
+
159
+ ```yaml
160
+ repos:
161
+ - repo: https://github.com/adewanggar/ghostbuster-cli
162
+ rev: v0.2.0
163
+ hooks:
164
+ - id: ghostbuster-scan
165
+ ```
166
+
149
167
  ### CI Integration
150
168
 
151
169
  ```yaml
@@ -72,9 +72,9 @@ ghostbuster bust --confirm
72
72
  ```
73
73
 
74
74
  Currently auto-fixes:
75
- - Removes unused import statements
76
- - Suggests `.gitignore` additions for orphan files
77
- - Suggests `.env` additions for phantom env vars
75
+ - Removes unused import statements from Python files
76
+ - Appends missing orphan files and directories to `.gitignore`
77
+ - Appends missing environment variable stubs (`KEY=`) to `.env.example`
78
78
 
79
79
  ## Usage
80
80
 
@@ -114,6 +114,18 @@ ghostbuster scan --format json
114
114
  ghostbuster scan --format markdown
115
115
  ```
116
116
 
117
+ ### Pre-commit Hook Integration
118
+
119
+ Add Ghostbuster to your `.pre-commit-config.yaml`:
120
+
121
+ ```yaml
122
+ repos:
123
+ - repo: https://github.com/adewanggar/ghostbuster-cli
124
+ rev: v0.2.0
125
+ hooks:
126
+ - id: ghostbuster-scan
127
+ ```
128
+
117
129
  ### CI Integration
118
130
 
119
131
  ```yaml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostbuster-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Find and bust the ghosts haunting your codebase - unused deps, dead code, orphan files, and phantom env vars."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -42,6 +42,15 @@ dependencies = [
42
42
  "tomli>=2.0.0; python_version < '3.11'",
43
43
  ]
44
44
 
45
+ [project.optional-dependencies]
46
+ dev = [
47
+ "pytest>=8.0.0",
48
+ "ruff>=0.4.0",
49
+ "mypy>=1.10.0",
50
+ "build>=1.0.0",
51
+ "twine>=5.0.0",
52
+ ]
53
+
45
54
  [project.scripts]
46
55
  ghostbuster = "ghostbuster.cli.app:app"
47
56
 
@@ -97,5 +106,4 @@ warn_return_any = true
97
106
  warn_unused_configs = true
98
107
  disallow_untyped_defs = true
99
108
  check_untyped_defs = true
100
- mypy_path = "src"
101
- packages = ["ghostbuster"]
109
+ ignore_missing_imports = true
@@ -1,3 +1,3 @@
1
1
  """Ghostbuster — Find and bust the ghosts haunting your codebase."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -22,7 +22,7 @@ from ghostbuster.cli.display import (
22
22
  )
23
23
  from ghostbuster.core.models import GhostCategory
24
24
  from ghostbuster.core.scanner import create_default_orchestrator
25
- from ghostbuster.fixers.import_fixer import ImportFixer
25
+ from ghostbuster.fixers import EnvFixer, Fixer, GitignoreFixer, ImportFixer
26
26
 
27
27
 
28
28
  def bust(
@@ -101,16 +101,28 @@ def bust(
101
101
 
102
102
  console.print(f" Found [bold]{len(fixable_ghosts)}[/bold] fixable ghosts.\n")
103
103
 
104
- # Apply fixes per category
105
- fixer = ImportFixer()
104
+ # Run fixers across all categories
105
+ fixers: list[Fixer] = [
106
+ ImportFixer(),
107
+ GitignoreFixer(),
108
+ EnvFixer(),
109
+ ]
110
+
111
+ all_changes: list[str] = []
106
112
 
107
113
  if confirm:
108
- changes = fixer.fix(fixable_ghosts, path)
109
- print_bust_applied(changes)
114
+ for fixer in fixers:
115
+ changes = fixer.fix(fixable_ghosts, path)
116
+ all_changes.extend(changes)
117
+
118
+ print_bust_applied(all_changes)
110
119
  console.print(
111
120
  "\n [bold green]Ghostbusting complete![/bold green] "
112
121
  "Don't forget to review the changes and run your tests.\n"
113
122
  )
114
123
  else:
115
- changes = fixer.preview(fixable_ghosts, path)
116
- print_bust_preview(changes)
124
+ for fixer in fixers:
125
+ changes = fixer.preview(fixable_ghosts, path)
126
+ all_changes.extend(changes)
127
+
128
+ print_bust_preview(all_changes)
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  import sys
12
12
  from dataclasses import dataclass, field
13
13
  from pathlib import Path
14
+ from typing import Any, cast
14
15
 
15
16
 
16
17
  @dataclass
@@ -84,7 +85,7 @@ class GhostbusterConfig:
84
85
  return config
85
86
 
86
87
 
87
- def _load_toml(filepath: Path) -> dict:
88
+ def _load_toml(filepath: Path) -> dict[str, Any]:
88
89
  """Load a TOML file and return its contents as a dict."""
89
90
  try:
90
91
  if sys.version_info >= (3, 11):
@@ -92,25 +93,26 @@ def _load_toml(filepath: Path) -> dict:
92
93
  else:
93
94
  import tomli as tomllib # type: ignore[no-redef]
94
95
 
95
- return tomllib.loads(filepath.read_text(encoding="utf-8"))
96
+ data = tomllib.loads(filepath.read_text(encoding="utf-8"))
97
+ return cast("dict[str, Any]", data) if isinstance(data, dict) else {}
96
98
  except Exception:
97
99
  return {}
98
100
 
99
101
 
100
- def _apply_config(config: GhostbusterConfig, data: dict) -> GhostbusterConfig:
102
+ def _apply_config(config: GhostbusterConfig, data: dict[str, Any]) -> GhostbusterConfig:
101
103
  """Apply a dict of config values to a GhostbusterConfig."""
102
- if "exclude_dirs" in data:
104
+ if "exclude_dirs" in data and isinstance(data["exclude_dirs"], list):
103
105
  config.exclude_dirs = data["exclude_dirs"]
104
- if "exclude_patterns" in data:
106
+ if "exclude_patterns" in data and isinstance(data["exclude_patterns"], list):
105
107
  config.exclude_patterns = data["exclude_patterns"]
106
- if "categories" in data:
108
+ if "categories" in data and isinstance(data["categories"], list):
107
109
  config.categories = data["categories"]
108
- if "ignore_packages" in data:
110
+ if "ignore_packages" in data and isinstance(data["ignore_packages"], list):
109
111
  config.ignore_packages = data["ignore_packages"]
110
- if "ignore_env_vars" in data:
112
+ if "ignore_env_vars" in data and isinstance(data["ignore_env_vars"], list):
111
113
  config.ignore_env_vars = data["ignore_env_vars"]
112
- if "ignore_names" in data:
114
+ if "ignore_names" in data and isinstance(data["ignore_names"], list):
113
115
  config.ignore_names = data["ignore_names"]
114
- if "large_file_threshold" in data:
116
+ if "large_file_threshold" in data and isinstance(data["large_file_threshold"], int):
115
117
  config.large_file_threshold = data["large_file_threshold"]
116
118
  return config
@@ -0,0 +1,29 @@
1
+ """Auto-fix modules for resolving ghost findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ from ghostbuster.fixers.env_fixer import EnvFixer
8
+ from ghostbuster.fixers.gitignore_fixer import GitignoreFixer
9
+ from ghostbuster.fixers.import_fixer import ImportFixer
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from ghostbuster.core.models import Ghost
15
+
16
+
17
+ class Fixer(Protocol):
18
+ """Protocol that all fixers must implement."""
19
+
20
+ def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
21
+ """Return preview of changes without applying them."""
22
+ ...
23
+
24
+ def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
25
+ """Apply fixes and return list of changes made."""
26
+ ...
27
+
28
+
29
+ __all__ = ["EnvFixer", "Fixer", "GitignoreFixer", "ImportFixer"]
@@ -0,0 +1,112 @@
1
+ """Env Fixer - adds phantom environment variables to .env.example or .env."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from ghostbuster.core.models import Ghost, GhostCategory
9
+
10
+
11
+ class EnvFixer:
12
+ """Auto-fixer that appends missing environment variable stubs to .env.example."""
13
+
14
+ def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
15
+ """Return a list of changes that would be made without modifying files."""
16
+ env_ghosts = [g for g in ghosts if g.category == GhostCategory.PHANTOM_ENV and g.fixable]
17
+ if not env_ghosts:
18
+ return []
19
+
20
+ target_file = self._get_target_file(path)
21
+ keys_to_add = self._get_keys_to_add(env_ghosts, target_file)
22
+ if not keys_to_add:
23
+ return []
24
+
25
+ target_name = target_file.name if target_file.exists() else f"{target_file.name} (new file)"
26
+
27
+ changes: list[str] = []
28
+ for key in keys_to_add:
29
+ changes.append(f" Would add '{key}=' to {target_name}")
30
+
31
+ return changes
32
+
33
+ def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
34
+ """Append missing environment variable keys to .env.example or .env."""
35
+ env_ghosts = [g for g in ghosts if g.category == GhostCategory.PHANTOM_ENV and g.fixable]
36
+ if not env_ghosts:
37
+ return []
38
+
39
+ target_file = self._get_target_file(path)
40
+ keys_to_add = self._get_keys_to_add(env_ghosts, target_file)
41
+ if not keys_to_add:
42
+ return []
43
+
44
+ existing_content = ""
45
+ if target_file.exists():
46
+ try:
47
+ existing_content = target_file.read_text(encoding="utf-8")
48
+ except OSError:
49
+ existing_content = ""
50
+
51
+ # Prepare new lines
52
+ new_lines: list[str] = []
53
+ if existing_content and not existing_content.endswith("\n"):
54
+ new_lines.append("\n")
55
+
56
+ for key in keys_to_add:
57
+ new_lines.append(f"{key}=\n")
58
+
59
+ try:
60
+ with target_file.open("a", encoding="utf-8") as f:
61
+ f.write("".join(new_lines))
62
+ except OSError:
63
+ return []
64
+
65
+ applied: list[str] = []
66
+ for key in keys_to_add:
67
+ applied.append(f" Added '{key}=' to {target_file.name}")
68
+
69
+ return applied
70
+
71
+ def _get_target_file(self, path: Path) -> Path:
72
+ """Get the preferred env file to update."""
73
+ env_example = path / ".env.example"
74
+ if env_example.exists():
75
+ return env_example
76
+
77
+ env_sample = path / ".env.sample"
78
+ if env_sample.exists():
79
+ return env_sample
80
+
81
+ env_template = path / ".env.template"
82
+ if env_template.exists():
83
+ return env_template
84
+
85
+ # Default to .env.example to prevent accidental secret commits
86
+ return path / ".env.example"
87
+
88
+ def _get_keys_to_add(self, ghosts: list[Ghost], target_file: Path) -> list[str]:
89
+ """Determine unique environment variable keys that need to be added."""
90
+ existing_keys: set[str] = set()
91
+
92
+ if target_file.exists():
93
+ try:
94
+ for line in target_file.read_text(encoding="utf-8").splitlines():
95
+ cleaned = line.strip()
96
+ if cleaned and not cleaned.startswith("#"):
97
+ match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=", cleaned)
98
+ if match:
99
+ existing_keys.add(match.group(1))
100
+ except OSError:
101
+ pass
102
+
103
+ keys: list[str] = []
104
+ seen: set[str] = set()
105
+
106
+ for ghost in ghosts:
107
+ key = ghost.name
108
+ if key not in existing_keys and key not in seen:
109
+ seen.add(key)
110
+ keys.append(key)
111
+
112
+ return keys
@@ -0,0 +1,101 @@
1
+ """Gitignore Fixer - adds orphan files and directories to .gitignore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ghostbuster.core.models import Ghost, GhostCategory
8
+
9
+
10
+ class GitignoreFixer:
11
+ """Auto-fixer that appends orphan files/directories to .gitignore."""
12
+
13
+ def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
14
+ """Return a list of changes that would be made without modifying files."""
15
+ orphan_ghosts = [g for g in ghosts if g.category == GhostCategory.ORPHAN_FILE and g.fixable]
16
+ if not orphan_ghosts:
17
+ return []
18
+
19
+ entries_to_add = self._get_entries_to_add(orphan_ghosts, path)
20
+ if not entries_to_add:
21
+ return []
22
+
23
+ gitignore_path = path / ".gitignore"
24
+ target_name = ".gitignore" if gitignore_path.exists() else ".gitignore (new file)"
25
+
26
+ changes: list[str] = []
27
+ for entry in entries_to_add:
28
+ changes.append(f" Would add '{entry}' to {target_name}")
29
+
30
+ return changes
31
+
32
+ def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
33
+ """Append missing orphan patterns to .gitignore."""
34
+ orphan_ghosts = [g for g in ghosts if g.category == GhostCategory.ORPHAN_FILE and g.fixable]
35
+ if not orphan_ghosts:
36
+ return []
37
+
38
+ entries_to_add = self._get_entries_to_add(orphan_ghosts, path)
39
+ if not entries_to_add:
40
+ return []
41
+
42
+ gitignore_path = path / ".gitignore"
43
+ existing_content = ""
44
+ if gitignore_path.exists():
45
+ try:
46
+ existing_content = gitignore_path.read_text(encoding="utf-8")
47
+ except OSError:
48
+ existing_content = ""
49
+
50
+ # Prepare new content
51
+ new_lines: list[str] = []
52
+ if existing_content and not existing_content.endswith("\n"):
53
+ new_lines.append("\n")
54
+
55
+ for entry in entries_to_add:
56
+ new_lines.append(f"{entry}\n")
57
+
58
+ try:
59
+ with gitignore_path.open("a", encoding="utf-8") as f:
60
+ f.write("".join(new_lines))
61
+ except OSError:
62
+ return []
63
+
64
+ applied: list[str] = []
65
+ for entry in entries_to_add:
66
+ applied.append(f" Added '{entry}' to .gitignore")
67
+
68
+ return applied
69
+
70
+ def _get_entries_to_add(self, ghosts: list[Ghost], path: Path) -> list[str]:
71
+ """Determine unique entries that need to be added to .gitignore."""
72
+ gitignore_path = path / ".gitignore"
73
+ existing_lines: set[str] = set()
74
+
75
+ if gitignore_path.exists():
76
+ try:
77
+ for line in gitignore_path.read_text(encoding="utf-8").splitlines():
78
+ cleaned = line.strip()
79
+ if cleaned and not cleaned.startswith("#"):
80
+ existing_lines.add(cleaned.rstrip("/"))
81
+ except OSError:
82
+ pass
83
+
84
+ entries: list[str] = []
85
+ seen: set[str] = set()
86
+
87
+ for ghost in ghosts:
88
+ # Determine appropriate pattern
89
+ name = ghost.name
90
+ if ghost.file_path and ghost.file_path.is_dir():
91
+ pattern = f"{name}/"
92
+ check_name = name.rstrip("/")
93
+ else:
94
+ pattern = name
95
+ check_name = name
96
+
97
+ if check_name not in existing_lines and pattern not in seen:
98
+ seen.add(pattern)
99
+ entries.append(pattern)
100
+
101
+ return entries
@@ -17,7 +17,7 @@ class TestCLI:
17
17
  result = runner.invoke(app, ["--version"])
18
18
  assert result.exit_code == 0
19
19
  assert "ghostbuster" in result.output
20
- assert "0.1.0" in result.output
20
+ assert "0.2.0" in result.output
21
21
 
22
22
  def test_help_flag(self) -> None:
23
23
  """--help should print help text."""
@@ -0,0 +1,171 @@
1
+ """Tests for Ghostbuster auto-fixers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ghostbuster.core.models import Ghost, GhostCategory, Severity
8
+ from ghostbuster.fixers.env_fixer import EnvFixer
9
+ from ghostbuster.fixers.gitignore_fixer import GitignoreFixer
10
+
11
+
12
+ class TestGitignoreFixer:
13
+ """Tests for GitignoreFixer."""
14
+
15
+ def test_preview_gitignore_changes(self, tmp_path: Path) -> None:
16
+ """Should preview adding orphan directories/files to .gitignore."""
17
+ node_modules = tmp_path / "node_modules"
18
+ node_modules.mkdir()
19
+
20
+ ghosts = [
21
+ Ghost(
22
+ category=GhostCategory.ORPHAN_FILE,
23
+ name="node_modules",
24
+ message="Directory node_modules should be in .gitignore",
25
+ file_path=node_modules,
26
+ severity=Severity.HIGH,
27
+ fixable=True,
28
+ ),
29
+ Ghost(
30
+ category=GhostCategory.ORPHAN_FILE,
31
+ name=".DS_Store",
32
+ message="File .DS_Store should be in .gitignore",
33
+ file_path=tmp_path / ".DS_Store",
34
+ severity=Severity.LOW,
35
+ fixable=True,
36
+ ),
37
+ ]
38
+
39
+ fixer = GitignoreFixer()
40
+ preview = fixer.preview(ghosts, tmp_path)
41
+
42
+ assert len(preview) == 2
43
+ assert any("node_modules/" in p for p in preview)
44
+ assert any(".DS_Store" in p for p in preview)
45
+ # .gitignore should NOT be created during preview
46
+ assert not (tmp_path / ".gitignore").exists()
47
+
48
+ def test_apply_gitignore_fix(self, tmp_path: Path) -> None:
49
+ """Should append missing entries to .gitignore."""
50
+ (tmp_path / "venv").mkdir()
51
+ (tmp_path / ".gitignore").write_text("__pycache__/\n", encoding="utf-8")
52
+
53
+ ghosts = [
54
+ Ghost(
55
+ category=GhostCategory.ORPHAN_FILE,
56
+ name="venv",
57
+ message="venv should be in .gitignore",
58
+ file_path=tmp_path / "venv",
59
+ severity=Severity.HIGH,
60
+ fixable=True,
61
+ ),
62
+ ]
63
+
64
+ fixer = GitignoreFixer()
65
+ applied = fixer.fix(ghosts, tmp_path)
66
+
67
+ assert len(applied) == 1
68
+ content = (tmp_path / ".gitignore").read_text(encoding="utf-8")
69
+ assert "__pycache__/\n" in content
70
+ assert "venv/\n" in content
71
+
72
+ def test_skips_already_ignored_entries(self, tmp_path: Path) -> None:
73
+ """Should not add duplicate entries."""
74
+ (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8")
75
+ (tmp_path / "node_modules").mkdir()
76
+
77
+ ghosts = [
78
+ Ghost(
79
+ category=GhostCategory.ORPHAN_FILE,
80
+ name="node_modules",
81
+ message="Directory node_modules",
82
+ file_path=tmp_path / "node_modules",
83
+ severity=Severity.HIGH,
84
+ fixable=True,
85
+ ),
86
+ ]
87
+
88
+ fixer = GitignoreFixer()
89
+ applied = fixer.fix(ghosts, tmp_path)
90
+ assert len(applied) == 0
91
+
92
+
93
+ class TestEnvFixer:
94
+ """Tests for EnvFixer."""
95
+
96
+ def test_preview_env_changes(self, tmp_path: Path) -> None:
97
+ """Should preview adding missing env keys to .env.example."""
98
+ ghosts = [
99
+ Ghost(
100
+ category=GhostCategory.PHANTOM_ENV,
101
+ name="DATABASE_URL",
102
+ message="DATABASE_URL is missing",
103
+ severity=Severity.HIGH,
104
+ fixable=True,
105
+ ),
106
+ Ghost(
107
+ category=GhostCategory.PHANTOM_ENV,
108
+ name="STRIPE_SECRET_KEY",
109
+ message="STRIPE_SECRET_KEY is missing",
110
+ severity=Severity.HIGH,
111
+ fixable=True,
112
+ ),
113
+ ]
114
+
115
+ fixer = EnvFixer()
116
+ preview = fixer.preview(ghosts, tmp_path)
117
+
118
+ assert len(preview) == 2
119
+ assert any("DATABASE_URL=" in p for p in preview)
120
+ assert any("STRIPE_SECRET_KEY=" in p for p in preview)
121
+ assert not (tmp_path / ".env.example").exists()
122
+
123
+ def test_apply_env_fix(self, tmp_path: Path) -> None:
124
+ """Should create .env.example and append missing keys."""
125
+ ghosts = [
126
+ Ghost(
127
+ category=GhostCategory.PHANTOM_ENV,
128
+ name="API_KEY",
129
+ message="API_KEY is missing",
130
+ severity=Severity.HIGH,
131
+ fixable=True,
132
+ ),
133
+ ]
134
+
135
+ fixer = EnvFixer()
136
+ applied = fixer.fix(ghosts, tmp_path)
137
+
138
+ assert len(applied) == 1
139
+ env_example = tmp_path / ".env.example"
140
+ assert env_example.exists()
141
+ content = env_example.read_text(encoding="utf-8")
142
+ assert "API_KEY=\n" in content
143
+
144
+ def test_preserves_existing_keys_in_env_example(self, tmp_path: Path) -> None:
145
+ """Should append new keys without duplicating existing ones."""
146
+ (tmp_path / ".env.example").write_text("EXISTING_KEY=123\n", encoding="utf-8")
147
+
148
+ ghosts = [
149
+ Ghost(
150
+ category=GhostCategory.PHANTOM_ENV,
151
+ name="EXISTING_KEY",
152
+ message="Already in example",
153
+ severity=Severity.HIGH,
154
+ fixable=True,
155
+ ),
156
+ Ghost(
157
+ category=GhostCategory.PHANTOM_ENV,
158
+ name="NEW_KEY",
159
+ message="New key",
160
+ severity=Severity.HIGH,
161
+ fixable=True,
162
+ ),
163
+ ]
164
+
165
+ fixer = EnvFixer()
166
+ applied = fixer.fix(ghosts, tmp_path)
167
+
168
+ assert len(applied) == 1
169
+ content = (tmp_path / ".env.example").read_text(encoding="utf-8")
170
+ assert "EXISTING_KEY=123\n" in content
171
+ assert "NEW_KEY=\n" in content
@@ -1 +0,0 @@
1
- """Auto-fix modules for resolving ghost findings."""
File without changes