toolbelt-cli 0.2.0__py3-none-any.whl

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 (46) hide show
  1. toolbelt/__init__.py +4 -0
  2. toolbelt/cli.py +46 -0
  3. toolbelt/commands/__init__.py +1 -0
  4. toolbelt/commands/audit.py +63 -0
  5. toolbelt/commands/commit.py +84 -0
  6. toolbelt/commands/new.py +116 -0
  7. toolbelt/commands/version.py +47 -0
  8. toolbelt/services/__init__.py +1 -0
  9. toolbelt/services/auditor.py +295 -0
  10. toolbelt/services/llm.py +105 -0
  11. toolbelt/services/scaffolder.py +91 -0
  12. toolbelt/templates/java/.gitignore.j2 +7 -0
  13. toolbelt/templates/java/LICENSE.j2 +39 -0
  14. toolbelt/templates/java/README.md.j2 +26 -0
  15. toolbelt/templates/java/pom.xml.j2 +53 -0
  16. toolbelt/templates/java/src/main/java/com/{{project_name}}/App.java.j2 +7 -0
  17. toolbelt/templates/java/src/test/java/com/{{project_name}}/AppTest.java.j2 +13 -0
  18. toolbelt/templates/node/.eslintrc.json.j2 +10 -0
  19. toolbelt/templates/node/.gitignore.j2 +7 -0
  20. toolbelt/templates/node/.prettierrc.j2 +5 -0
  21. toolbelt/templates/node/LICENSE.j2 +39 -0
  22. toolbelt/templates/node/README.md.j2 +26 -0
  23. toolbelt/templates/node/package.json.j2 +20 -0
  24. toolbelt/templates/node/src/index.test.ts.j2 +6 -0
  25. toolbelt/templates/node/src/index.ts.j2 +7 -0
  26. toolbelt/templates/node/tsconfig.json.j2 +14 -0
  27. toolbelt/templates/python/.github/workflows/ci.yml.j2 +28 -0
  28. toolbelt/templates/python/.gitignore.j2 +42 -0
  29. toolbelt/templates/python/.pre-commit-config.yaml.j2 +13 -0
  30. toolbelt/templates/python/Dockerfile.j2 +10 -0
  31. toolbelt/templates/python/LICENSE.j2 +39 -0
  32. toolbelt/templates/python/Makefile.j2 +17 -0
  33. toolbelt/templates/python/README.md.j2 +19 -0
  34. toolbelt/templates/python/pyproject.toml.j2 +28 -0
  35. toolbelt/templates/python/src/{{project_name}}/__init__.py.j2 +1 -0
  36. toolbelt/templates/python/src/{{project_name}}/main.py.j2 +7 -0
  37. toolbelt/templates/python/tests/test_main.py.j2 +8 -0
  38. toolbelt/utils/__init__.py +1 -0
  39. toolbelt/utils/console.py +43 -0
  40. toolbelt/utils/git.py +54 -0
  41. toolbelt_cli-0.2.0.dist-info/METADATA +382 -0
  42. toolbelt_cli-0.2.0.dist-info/RECORD +46 -0
  43. toolbelt_cli-0.2.0.dist-info/WHEEL +5 -0
  44. toolbelt_cli-0.2.0.dist-info/entry_points.txt +3 -0
  45. toolbelt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  46. toolbelt_cli-0.2.0.dist-info/top_level.txt +1 -0
toolbelt/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Toolbelt CLI package."""
2
+
3
+ __version__ = "0.2.0"
4
+ __all__ = ["__version__"]
toolbelt/cli.py ADDED
@@ -0,0 +1,46 @@
1
+ """Entry point for the Toolbelt CLI using Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .commands import audit, commit, new, version
8
+
9
+ app = typer.Typer(
10
+ name="toolbelt",
11
+ help=(
12
+ "Toolbelt – developer productivity CLI: scaffolding, AI commits, and audits."
13
+ ),
14
+ rich_markup_mode="rich",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ # Explicitly register subcommands with designated names
19
+ app.command(
20
+ name="new",
21
+ help="Scaffold a new project (python, node, java) with opinionated defaults.",
22
+ )(new.main)
23
+
24
+ app.command(
25
+ name="commit",
26
+ help="Generate Conventional Commit messages from diff using Claude API.",
27
+ )(commit.main)
28
+
29
+ app.command(
30
+ name="audit",
31
+ help="Audit repo for README sections, .gitignore, licenses, and CI.",
32
+ )(audit.main)
33
+
34
+ app.command(
35
+ name="version",
36
+ help="Print installed version and check PyPI for updates.",
37
+ )(version.main)
38
+
39
+
40
+ def main() -> None:
41
+ """Run the Toolbelt CLI application."""
42
+ app()
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
@@ -0,0 +1 @@
1
+ """Toolbelt command modules."""
@@ -0,0 +1,63 @@
1
+ """Implementation of `toolbelt audit [path]` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich import box
10
+ from rich.table import Table
11
+
12
+ from ..services.auditor import Auditor
13
+ from ..utils.console import get_console
14
+
15
+
16
+ def main(
17
+ path: str = typer.Argument(".", help="Path to repository to audit."),
18
+ json_output: bool = typer.Option(
19
+ False, "--json", help="Output machine-readable JSON."
20
+ ),
21
+ fix: bool = typer.Option(
22
+ False, "--fix", help="Auto-create missing .gitignore and LICENSE."
23
+ ),
24
+ ) -> None:
25
+ """Audit repo for README sections, .gitignore, licenses, and CI."""
26
+ console = get_console()
27
+ repo_path = Path(path).resolve()
28
+
29
+ if not repo_path.exists() or not repo_path.is_dir():
30
+ console.print(f"[bold red]Error:[/bold red] Invalid directory: {path}")
31
+ raise typer.Exit(code=1)
32
+
33
+ auditor = Auditor(repo_path, console=console)
34
+ results = auditor.run(fix=fix)
35
+
36
+ if json_output:
37
+ data = [r.to_dict() for r in results]
38
+ console.print(json.dumps(data, indent=2))
39
+ else:
40
+ table = Table(
41
+ title=f"Repository Audit: {repo_path.name}",
42
+ box=box.ROUNDED,
43
+ show_header=True,
44
+ header_style="bold cyan",
45
+ )
46
+ table.add_column("Check", style="bold white", min_width=25)
47
+ table.add_column("Status", justify="center", width=8)
48
+ table.add_column("Details", style="dim white")
49
+
50
+ for res in results:
51
+ status_symbol = (
52
+ "[bold green]✅[/bold green]"
53
+ if res.passed
54
+ else "[bold red]❌[/bold red]"
55
+ )
56
+ table.add_row(res.name, status_symbol, res.details)
57
+
58
+ console.print(table)
59
+
60
+ all_passed = all(r.passed for r in results)
61
+ if not all_passed:
62
+ raise typer.Exit(code=1)
63
+ raise typer.Exit(code=0)
@@ -0,0 +1,84 @@
1
+ """Implementation of `toolbelt commit` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.panel import Panel
7
+ from rich.prompt import Prompt
8
+
9
+ from ..services.llm import CommitMessageGenerator
10
+ from ..utils.console import get_console
11
+ from ..utils.git import git_commit, git_diff_staged
12
+
13
+
14
+ def main(
15
+ dry_run: bool = typer.Option(
16
+ False, "--dry-run", help="Print proposed commit message without committing."
17
+ ),
18
+ commit_type: str | None = typer.Option(
19
+ None, "--type", "-t", help="Force commit type (feat, fix, docs, etc.)."
20
+ ),
21
+ scope: str | None = typer.Option(None, "--scope", "-s", help="Force commit scope."),
22
+ yes: bool = typer.Option(
23
+ False, "--yes", "-y", help="Skip confirmation prompt and commit immediately."
24
+ ),
25
+ ) -> None:
26
+ """Generate Conventional Commit message from staged diff using Claude."""
27
+ console = get_console()
28
+
29
+ try:
30
+ diff = git_diff_staged()
31
+ except Exception as exc:
32
+ console.print(f"[bold red]Git error:[/bold red] {exc}")
33
+ raise typer.Exit(code=1) from exc
34
+
35
+ if not diff or not diff.strip():
36
+ console.print("[bold red]Nothing staged. Run git add first.[/bold red]")
37
+ raise typer.Exit(code=1)
38
+
39
+ generator = CommitMessageGenerator()
40
+ message = generator.generate(diff, commit_type=commit_type, scope=scope)
41
+
42
+ if len(message.strip()) < 10:
43
+ console.print(
44
+ "[bold red]Commit message too short (< 10 chars); aborting.[/bold red]"
45
+ )
46
+ raise typer.Exit(code=1)
47
+
48
+ console.print(Panel(message, title="Proposed Commit Message", border_style="cyan"))
49
+
50
+ if dry_run:
51
+ console.print("[green]Dry run – commit not performed.[/green]")
52
+ raise typer.Exit(code=0)
53
+
54
+ if not yes:
55
+ choice = (
56
+ Prompt.ask(
57
+ "Apply commit? [y/n/edit]",
58
+ choices=["y", "n", "edit", "e"],
59
+ default="y",
60
+ )
61
+ .strip()
62
+ .lower()
63
+ )
64
+
65
+ if choice in {"n", "no"}:
66
+ console.print("[yellow]Commit aborted by user.[/yellow]")
67
+ raise typer.Exit(code=1)
68
+
69
+ if choice in {"edit", "e"}:
70
+ edited = Prompt.ask("Edit message", default=message).strip()
71
+ if len(edited) < 10:
72
+ console.print(
73
+ "[bold red]Commit message too short (< 10 chars).[/bold red]"
74
+ )
75
+ raise typer.Exit(code=1)
76
+ message = edited
77
+
78
+ try:
79
+ git_commit(message)
80
+ except Exception as exc:
81
+ console.print(f"[bold red]Commit failed:[/bold red] {exc}")
82
+ raise typer.Exit(code=1) from exc
83
+
84
+ console.print("[bold green]✅ Commit created successfully![/bold green]")
@@ -0,0 +1,116 @@
1
+ """Implementation of `toolbelt new <type> <name>` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich import box
9
+ from rich.panel import Panel
10
+ from rich.prompt import Confirm, Prompt
11
+
12
+ from ..services.scaffolder import Scaffolder
13
+ from ..utils.console import get_console
14
+ from ..utils.git import get_git_user_name
15
+
16
+
17
+ def main(
18
+ project_type: str = typer.Argument(..., help="Project type: python, node, java"),
19
+ name: str = typer.Argument(..., help="Project directory / package name"),
20
+ author: str | None = typer.Option(None, "--author", "-a", help="Author name"),
21
+ license_choice: str | None = typer.Option(
22
+ None, "--license", "-l", help="License: MIT, Apache-2.0, none"
23
+ ),
24
+ ci: bool | None = typer.Option(
25
+ None, "--ci/--no-ci", help="Include CI configuration"
26
+ ),
27
+ docker: bool | None = typer.Option(
28
+ None, "--docker/--no-docker", help="Include Dockerfile"
29
+ ),
30
+ yes: bool = typer.Option(
31
+ False, "--yes", "-y", help="Accept all defaults without prompting"
32
+ ),
33
+ ) -> None:
34
+ """Scaffold a new Python, Node, or Java project with opinionated structure."""
35
+ console = get_console()
36
+ normalized_type = project_type.strip().lower()
37
+
38
+ if normalized_type not in {"python", "node", "java"}:
39
+ console.print(
40
+ f"[bold red]Error:[/bold red] Unsupported project type '{project_type}'. "
41
+ "Choose from: python, node, java."
42
+ )
43
+ raise typer.Exit(code=1)
44
+
45
+ target_dir = Path(name).resolve()
46
+ if target_dir.exists() and any(target_dir.iterdir()):
47
+ console.print(
48
+ f"[bold red]Error:[/bold red] Target directory '{name}' "
49
+ "already exists and is not empty."
50
+ )
51
+ raise typer.Exit(code=1)
52
+
53
+ # Interactive prompts (or flag values / defaults)
54
+ default_author = get_git_user_name() or "Developer"
55
+ if author is None:
56
+ if yes:
57
+ author = default_author
58
+ else:
59
+ author = Prompt.ask("Author name", default=default_author)
60
+
61
+ if license_choice is None:
62
+ if yes:
63
+ license_choice = "MIT"
64
+ else:
65
+ license_choice = Prompt.ask(
66
+ "License",
67
+ choices=["MIT", "Apache-2.0", "none"],
68
+ default="MIT",
69
+ )
70
+ elif license_choice not in {"MIT", "Apache-2.0", "none"}:
71
+ console.print(
72
+ f"[bold red]Error:[/bold red] Invalid license '{license_choice}'. "
73
+ "Choose MIT, Apache-2.0, or none."
74
+ )
75
+ raise typer.Exit(code=1)
76
+
77
+ if ci is None:
78
+ if yes:
79
+ ci = True
80
+ else:
81
+ ci = Confirm.ask("Include CI? (y/n)", default=True)
82
+
83
+ if docker is None:
84
+ if yes:
85
+ docker = False
86
+ else:
87
+ docker = Confirm.ask("Include Docker? (y/n)", default=False)
88
+
89
+ scaffolder = Scaffolder(
90
+ project_type=normalized_type,
91
+ project_name=target_dir.name,
92
+ author=author,
93
+ license=license_choice,
94
+ include_ci=ci,
95
+ include_docker=docker,
96
+ destination=target_dir,
97
+ )
98
+ scaffolder.run()
99
+
100
+ if normalized_type == "python":
101
+ commands = f"cd {target_dir.name} && make install && make test"
102
+ elif normalized_type == "node":
103
+ commands = f"cd {target_dir.name} && npm install && npm test"
104
+ else:
105
+ commands = f"cd {target_dir.name} && mvn clean test"
106
+
107
+ console.print(
108
+ Panel(
109
+ f"[bold green]✨ Project successfully scaffolded![/bold green]\n\n"
110
+ f"[dim]Next steps:[/dim]\n"
111
+ f"[bold cyan]{commands}[/bold cyan]",
112
+ title="🎉 Toolbelt Scaffold",
113
+ border_style="green",
114
+ box=box.ROUNDED,
115
+ )
116
+ )
@@ -0,0 +1,47 @@
1
+ """Implementation of `toolbelt version` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ import httpx
8
+ from rich.panel import Panel
9
+
10
+ from ..utils.console import get_console
11
+
12
+
13
+ def main() -> None:
14
+ """Print the current version and check PyPI for newer releases."""
15
+ console = get_console()
16
+ try:
17
+ current_version = importlib.metadata.version("toolbelt-cli")
18
+ except Exception:
19
+ current_version = "0.2.0"
20
+
21
+ console.print(
22
+ Panel(
23
+ f"[bold]toolbelt-cli version:[/bold] {current_version}",
24
+ style="green",
25
+ border_style="green",
26
+ )
27
+ )
28
+
29
+ pypi_url = "https://pypi.org/pypi/toolbelt-cli/json"
30
+ try:
31
+ resp = httpx.get(pypi_url, timeout=5.0)
32
+ resp.raise_for_status()
33
+ data = resp.json()
34
+ latest = data.get("info", {}).get("version")
35
+ if latest and latest != current_version:
36
+ console.print(
37
+ Panel(
38
+ f"[yellow]A newer version {latest} is available![/yellow]\n"
39
+ f"Run: [bold cyan]pip install -U toolbelt-cli[/bold cyan]",
40
+ style="yellow",
41
+ border_style="yellow",
42
+ title="Update Available",
43
+ )
44
+ )
45
+ except Exception:
46
+ # Ignore network errors or unavailable PyPI endpoint
47
+ pass
@@ -0,0 +1 @@
1
+ """Service layer for Toolbelt CLI."""
@@ -0,0 +1,295 @@
1
+ """Repository auditing service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+ from rich.prompt import Confirm, Prompt
11
+
12
+ MIT_TEMPLATE = """MIT License
13
+
14
+ Copyright (c) {year} {author}
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ of this software and associated documentation files (the "Software"), to deal
18
+ in the Software without restriction, including without limitation the rights
19
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
+ copies of the Software, and to permit persons to whom the Software is
21
+ furnished to do so, subject to the following conditions:
22
+
23
+ The above copyright notice and this permission notice shall be included in all
24
+ copies or substantial portions of the Software.
25
+
26
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ SOFTWARE.
33
+ """
34
+
35
+ APACHE_TEMPLATE = """ Apache License
36
+ Version 2.0, January 2004
37
+ http://www.apache.org/licenses/
38
+
39
+ Copyright (c) {year} {author}
40
+
41
+ Licensed under the Apache License, Version 2.0 (the "License");
42
+ you may not use this file except in compliance with the License.
43
+ You may obtain a copy of the License at
44
+
45
+ http://www.apache.org/licenses/LICENSE-2.0
46
+
47
+ Unless required by applicable law or agreed to in writing, software
48
+ distributed under the License is distributed on an "AS IS" BASIS,
49
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
50
+ See the License for the specific language governing permissions and
51
+ limitations under the License.
52
+ """
53
+
54
+ DEFAULT_GITIGNORE = """# Python
55
+ __pycache__/
56
+ *.py[cod]
57
+ *$py.class
58
+ *.so
59
+ build/
60
+ develop-eggs/
61
+ dist/
62
+ downloads/
63
+ eggs/
64
+ .eggs/
65
+ lib/
66
+ lib64/
67
+ parts/
68
+ sdist/
69
+ var/
70
+ wheels/
71
+ *.egg-info/
72
+ .installed.cfg
73
+ *.egg
74
+ .env
75
+ .venv
76
+ env/
77
+ venv/
78
+ ENV/
79
+
80
+ # Node
81
+ node_modules/
82
+ npm-debug.log*
83
+ yarn-debug.log*
84
+ yarn-error.log*
85
+
86
+ # Java
87
+ target/
88
+ *.class
89
+
90
+ # IDE
91
+ .idea/
92
+ .vscode/
93
+ *.swp
94
+ """
95
+
96
+
97
+ class AuditResult:
98
+ """Individual audit check result."""
99
+
100
+ def __init__(self, name: str, passed: bool, details: str = "") -> None:
101
+ self.name = name
102
+ self.passed = passed
103
+ self.details = details
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "name": self.name,
108
+ "passed": self.passed,
109
+ "status": "✅" if self.passed else "❌",
110
+ "details": self.details,
111
+ }
112
+
113
+
114
+ class Auditor:
115
+ """Inspects a repository path for standards, files, and conventions."""
116
+
117
+ def __init__(self, repo_path: Path, console: Console | None = None) -> None:
118
+ self.repo_path = repo_path.resolve()
119
+ self.console = console or Console()
120
+
121
+ def _file_content(self, relative_path: str) -> str | None:
122
+ target = self.repo_path / relative_path
123
+ if target.is_file():
124
+ try:
125
+ return target.read_text(encoding="utf-8")
126
+ except Exception:
127
+ return None
128
+ return None
129
+
130
+ def fix_missing(self, interactive: bool = True) -> None:
131
+ """Prompt to fix missing .gitignore and LICENSE."""
132
+ gitignore_path = self.repo_path / ".gitignore"
133
+ license_path = self.repo_path / "LICENSE"
134
+
135
+ if not gitignore_path.exists():
136
+ create_gi = True
137
+ if interactive:
138
+ try:
139
+ create_gi = Confirm.ask(
140
+ "Missing .gitignore. Create default .gitignore?", default=True
141
+ )
142
+ except Exception:
143
+ create_gi = True
144
+ if create_gi:
145
+ gitignore_path.write_text(DEFAULT_GITIGNORE, encoding="utf-8")
146
+ self.console.print("[green]Created .gitignore[/green]")
147
+
148
+ if not license_path.exists():
149
+ create_lic = True
150
+ choice = "MIT"
151
+ if interactive:
152
+ try:
153
+ create_lic = Confirm.ask(
154
+ "Missing LICENSE file. Create one?", default=True
155
+ )
156
+ if create_lic:
157
+ choice = Prompt.ask(
158
+ "Select license",
159
+ choices=["MIT", "Apache-2.0"],
160
+ default="MIT",
161
+ )
162
+ except Exception:
163
+ create_lic = True
164
+ choice = "MIT"
165
+ if create_lic:
166
+ template = APACHE_TEMPLATE if choice == "Apache-2.0" else MIT_TEMPLATE
167
+ content = template.format(year="2026", author="Author")
168
+ license_path.write_text(content, encoding="utf-8")
169
+ self.console.print(f"[green]Created LICENSE ({choice})[/green]")
170
+
171
+ def run(self, fix: bool = False, interactive: bool = True) -> list[AuditResult]:
172
+ """Run all audit checks."""
173
+ if fix:
174
+ self.fix_missing(interactive=interactive)
175
+
176
+ results: list[AuditResult] = []
177
+
178
+ # 1. README.md exists
179
+ readme_content = self._file_content("README.md")
180
+ if readme_content is not None:
181
+ line_count = len(readme_content.splitlines())
182
+ results.append(AuditResult("README.md exists", True, f"{line_count} lines"))
183
+ else:
184
+ results.append(AuditResult("README.md exists", False, "file missing"))
185
+
186
+ # 2. README has Install
187
+ if readme_content and re.search(
188
+ r"##\s+Install(ation)?", readme_content, re.IGNORECASE
189
+ ):
190
+ results.append(
191
+ AuditResult("README has Install", True, 'found "## Installation"')
192
+ )
193
+ else:
194
+ results.append(
195
+ AuditResult(
196
+ "README has Install", False, 'missing "## Installation" section'
197
+ )
198
+ )
199
+
200
+ # 3. README has Usage
201
+ if readme_content and re.search(r"##\s+Usage", readme_content, re.IGNORECASE):
202
+ results.append(AuditResult("README has Usage", True, 'found "## Usage"'))
203
+ else:
204
+ results.append(
205
+ AuditResult("README has Usage", False, 'missing "## Usage" section')
206
+ )
207
+
208
+ # 4. README has License
209
+ if readme_content and re.search(r"##\s+License", readme_content, re.IGNORECASE):
210
+ results.append(
211
+ AuditResult("README has License", True, 'found "## License"')
212
+ )
213
+ else:
214
+ results.append(
215
+ AuditResult("README has License", False, "no License section")
216
+ )
217
+
218
+ # 5. LICENSE file
219
+ license_content = self._file_content("LICENSE")
220
+ if license_content is not None:
221
+ if "Apache" in license_content:
222
+ lic_type = "Apache-2.0"
223
+ elif "MIT" in license_content:
224
+ lic_type = "MIT"
225
+ else:
226
+ first_line = (
227
+ license_content.strip().splitlines()[0][:20]
228
+ if license_content.strip()
229
+ else "Found"
230
+ )
231
+ lic_type = first_line
232
+ results.append(AuditResult("LICENSE file", True, lic_type))
233
+ else:
234
+ results.append(AuditResult("LICENSE file", False, "missing"))
235
+
236
+ # 6. .gitignore exists
237
+ gitignore_content = self._file_content(".gitignore")
238
+ if gitignore_content is not None:
239
+ entries = [
240
+ line
241
+ for line in gitignore_content.splitlines()
242
+ if line.strip() and not line.strip().startswith("#")
243
+ ]
244
+ results.append(
245
+ AuditResult(".gitignore exists", True, f"{len(entries)} entries")
246
+ )
247
+ else:
248
+ results.append(AuditResult(".gitignore exists", False, "file missing"))
249
+
250
+ # 7. .gitignore has __pycache__
251
+ if gitignore_content is not None and "__pycache__" in gitignore_content:
252
+ results.append(AuditResult(".gitignore has __pycache__", True, ""))
253
+ else:
254
+ results.append(
255
+ AuditResult(
256
+ ".gitignore has __pycache__", False, 'missing "__pycache__"'
257
+ )
258
+ )
259
+
260
+ # 8. Tests directory
261
+ tests_dir = self.repo_path / "tests"
262
+ test_dir = self.repo_path / "test"
263
+ src_test_dir = self.repo_path / "src" / "test"
264
+ has_tests = (
265
+ (tests_dir.is_dir() and any(tests_dir.iterdir()))
266
+ or (test_dir.is_dir() and any(test_dir.iterdir()))
267
+ or (src_test_dir.is_dir() and any(src_test_dir.iterdir()))
268
+ )
269
+ if has_tests:
270
+ results.append(AuditResult("Tests directory", True, "tests/ found"))
271
+ else:
272
+ results.append(AuditResult("Tests directory", False, "missing"))
273
+
274
+ # 9. CI config
275
+ ci_yml = self.repo_path / ".github" / "workflows" / "ci.yml"
276
+ ci_yaml = self.repo_path / ".github" / "workflows" / "ci.yaml"
277
+ workflows_dir = self.repo_path / ".github" / "workflows"
278
+ has_ci = (
279
+ ci_yml.exists()
280
+ or ci_yaml.exists()
281
+ or (workflows_dir.is_dir() and any(workflows_dir.glob("*.y*ml")))
282
+ )
283
+ if has_ci:
284
+ results.append(AuditResult("CI config", True, ".github/workflows/ci.yml"))
285
+ else:
286
+ results.append(AuditResult("CI config", False, "missing"))
287
+
288
+ # 10. pyproject.toml
289
+ pyproject = self.repo_path / "pyproject.toml"
290
+ if pyproject.is_file():
291
+ results.append(AuditResult("pyproject.toml", True, ""))
292
+ else:
293
+ results.append(AuditResult("pyproject.toml", False, "missing"))
294
+
295
+ return results