diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f11d271 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,117 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Python ${{ matrix.python-version }} / Ubuntu + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install project + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run tests + run: pytest + + quality: + name: Lint and package checks + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install development tools + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run Ruff + run: ruff check . + + - name: Validate PyPI README rendering + run: python -m readme_renderer README.md -o /tmp/explain-codebase-readme.html + + - name: Build distributions + run: python -m build + + - name: Check distributions + run: python -m twine check --strict dist/* + + - name: Install wheel in a clean environment + run: | + python -m venv .venv-smoke + .venv-smoke/bin/python -m pip install --upgrade pip + .venv-smoke/bin/python -m pip install dist/*.whl + + - name: Smoke-test installed command + run: | + .venv-smoke/bin/explain-codebase --help + .venv-smoke/bin/explain-codebase --version + .venv-smoke/bin/explain-codebase fixtures/python_cli_example --json + + windows-smoke: + name: Windows smoke test + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install project + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run tests + run: pytest + + - name: Smoke-test command + run: | + explain-codebase --help + explain-codebase --version + explain-codebase fixtures/python_cli_example --json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ef45da8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install build tools + run: python -m pip install --upgrade build "readme-renderer[md]>=44" twine + + - name: Verify tag matches package version + run: | + python - <<'PY' + import os + import tomllib + from pathlib import Path + + package_version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + tag_version = os.environ["GITHUB_REF_NAME"].removeprefix("v") + if tag_version != package_version: + raise SystemExit(f"Tag {tag_version!r} does not match package version {package_version!r}") + PY + + - name: Validate PyPI README rendering + run: python -m readme_renderer README.md -o /tmp/explain-codebase-readme.html + + - name: Build wheel and source distribution + run: python -m build + + - name: Validate distributions + run: python -m twine check --strict dist/* + + - name: Store distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/explain-codebase + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-distributions + path: dist/ + + - name: Publish distributions + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/.gitignore b/.gitignore index b7faf40..0ebe198 100644 --- a/.gitignore +++ b/.gitignore @@ -175,12 +175,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - # Visual Studio Code # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore @@ -194,13 +188,6 @@ cython_debug/ # PyPI configuration file .pypirc -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - # Marimo marimo/_static/ marimo/_lsp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2c5c359 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-08-02 + +### Added + +- Added support for `.jsx`, `.tsx`, `.mjs`, `.cjs`, `.mts`, and `.cts` source files alongside `.py`, `.js`, and `.ts`. +- Added an installed-version command and cross-version continuous integration checks. +- Added a tag-driven PyPI release workflow with version validation, package checks, artifact handoff, and Trusted Publishing. +- Added complete package metadata, project links, a dedicated changelog, and a security policy. + +### Changed + +- Hardened local scanning with repository-root containment, symlink rejection, an optional file-count limit, and a 1 MiB per-file limit. +- Limited public GitHub repository preparation to shallow single-branch clones without tags, added a clone timeout, and improved failure handling. +- Improved Python, JavaScript, and TypeScript import resolution across relative paths, package entry files, and supported extension variants. +- Made command-line validation, error handling, output-stream separation, and exit behavior more predictable. +- Expanded package, parser, scanner, remote-target, and command-line tests. +- Refreshed installation, usage, output, and limitation documentation. + +### Fixed + +- Declared `click` as a direct runtime dependency instead of relying on Typer's transitive dependency. +- Corrected Python package imports, multi-level JavaScript and TypeScript relative imports, async route detection, and project-root selection for file analysis. +- Prevented isolated files from being reported as high-coupling hotspots and rejected non-positive file limits. +- Kept ordinary filesystem-module imports from being reported as side effects until an actual filesystem operation is detected. + +### Security + +- Disabled repository-configured Git hooks and filesystem monitors, sanitized Git subprocess environments, and bounded Git operations with time limits. +- Pinned third-party workflow actions to immutable commit revisions. +- Rejected source-file links, Windows directory reparse points, paths outside the selected root, and source files larger than 1 MiB. +- Replaced the unbounded JavaScript import pattern with a line-bounded parser. +- Added atomic HTML output, enforced configured node limits for focused graph views, and added a content security policy plus integrity verification for the browser-side graph library. + +## [0.1.4] - 2026-03-19 + +### Changed + +- Redesigned the interactive dependency graph for clearer structure and navigation. +- Added architecture, entrypoint, risk, side-effect, and full file-level graph views. +- Updated graph embedding in HTML reports. + +## [0.1.3] - 2026-03-17 + +### Changed + +- Refined documentation, examples, and command descriptions. + +## [0.1.2] - 2026-03-17 + +### Added + +- Added Git-aware scanning, `.gitignore` filtering, and tracked-file selection. +- Added built-in filtering for common dependency, cache, build, coverage, and environment directories. + +## [0.1.1] - 2026-03-17 + +### Changed + +- Updated the package summary and release metadata. + +## [0.1.0] - 2026-03-17 + +### Added + +- Published the initial command-line release with repository analysis, dependency graphs, JSON output, HTML reports, onboarding paths, and architecture checks. + +[Unreleased]: https://github.com/danyasync/explain-codebase/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/danyasync/explain-codebase/compare/63e1285083b2bdb06de2212aeddcdaebb18e2649...v0.2.0 +[0.1.4]: https://pypi.org/project/explain-codebase/0.1.4/ +[0.1.3]: https://pypi.org/project/explain-codebase/0.1.3/ +[0.1.2]: https://pypi.org/project/explain-codebase/0.1.2/ +[0.1.1]: https://pypi.org/project/explain-codebase/0.1.1/ +[0.1.0]: https://pypi.org/project/explain-codebase/0.1.0/ diff --git a/README.md b/README.md index d3de377..b750139 100644 --- a/README.md +++ b/README.md @@ -1,272 +1,241 @@ # Explain Codebase -CLI tool for quickly mapping the architecture of an unfamiliar repository. +[](https://pypi.org/project/explain-codebase/) +[](https://pypi.org/project/explain-codebase/) +[](https://github.com/danyasync/explain-codebase/actions/workflows/ci.yml) +[](https://github.com/danyasync/explain-codebase/blob/main/LICENSE) -`explain-codebase` is a heuristic static-analysis CLI that helps developers find likely entrypoints, central modules, side-effect files, and risky areas in a codebase. +Static-analysis CLI for mapping repository architecture, dependencies, entry points, side effects, and change risk. -It is designed for onboarding and architecture review. It works with local folders and public GitHub repositories, and it aims to give you a useful architectural map quickly rather than perfectly understand every code path. +`explain-codebase` helps you find where execution starts, which files are central, how source files depend on one another, and where a change is likely to have the widest impact. It reads source files without importing or running the target project. -## Why +## Quick start -When you open a new repository, the first questions are usually: +Install from PyPI and inspect the current directory: -- where execution starts -- which modules are central -- which files touch the database, network, filesystem, or cache -- what files are risky to change -- where to begin onboarding - -`explain-codebase` scans the project, builds a dependency graph, and turns those signals into a compact CLI summary. - -## Changelog - -### v0.1.2 - -This release improves repository scanning by making the analyzer Git-aware. - -What's new: - -- supports `.gitignore`-aware scanning -- analyzes only files tracked by Git when the target is a Git repository -- ignores common noise directories such as `.venv`, `node_modules`, `dist`, `build`, `coverage`, and `__pycache__` -- produces cleaner dependency graphs and more accurate architecture summaries -- prevents generated and local-only files from polluting graph and report outputs +```bash +python -m pip install explain-codebase +explain-codebase . +``` -This makes the tool much more useful on real-world repositories by excluding ignored, temporary, and untracked files from the analysis. +For an isolated command-line installation, use `pipx`: -### v0.1.4 +```bash +pipx install explain-codebase +explain-codebase . +``` -Improved dependency graph visualization. +The default view is intentionally compact: -What’s new: +```text +Explain Codebase +-------------------------------- -- Redesigned dependency graph with a cleaner, more readable layout -- Improved node spacing and reduced visual noise -- Better handling of large repositories -- Smoother interactions and graph rendering +Repository -The graph is now easier to read and better represents the structure of real-world codebases. + Path C:\Projects\checkout-service + Type Python backend service + Language python + Files 7 -## Installation +Architecture -### Requirements + Entrypoints 1 + Core modules 5 + Side effects 4 -- Python 3.10+ -- Git, if you want to analyze remote GitHub repositories -- Best current support: Python, JavaScript, and TypeScript repositories +Suggested starting point -### Install from PyPI + api_server.py -```bash -pip install explain-codebase +Run with --verbose to see full architecture ``` -### For local development +## What it shows -```bash -pip install -e .[dev] -``` +- likely application entry points +- central modules ranked by dependency usage +- relative and package import relationships +- probable execution paths +- files that interact with databases, networks, filesystems, or caches +- common architecture areas such as services, repositories, routes, and controllers +- large files, highly connected files, circular dependencies, and risky change points +- a suggested reading order for onboarding +- focused dependency graphs and an HTML architecture report -## Commands +The results are heuristic signals intended to shorten initial investigation. They are not a substitute for reading critical code paths or running the target project's own checks. -### Overview - -Use this when you want a quick architectural snapshot of a repository: +## Installation -```bash -explain-codebase . -``` +### Requirements -### Detailed analysis +- Python 3.10 or newer +- Git when inspecting a public GitHub repository +- network access for remote repository checks and cloning -Use verbose mode when you want to inspect the likely architecture structure in more detail: +### From PyPI ```bash -explain-codebase . --verbose +python -m pip install explain-codebase ``` -Use deep mode when you want to focus on architectural risks and potential maintenance problems: +### Isolated CLI installation ```bash -explain-codebase . --deep +pipx install explain-codebase ``` -### File explanation - -Use this when you want to understand one specific file in project context: +### Local development ```bash -explain-codebase file src/services/api_server.py +python -m pip install -e ".[dev]" ``` -### Onboarding path - -Use this when a new developer needs a suggested reading order: +## Usage -```bash -explain-codebase onboarding . -``` +### Common commands -### Graph and report +| Goal | Command | +| --- | --- | +| Inspect the current directory | `explain-codebase .` | +| Inspect another local directory | `explain-codebase path/to/repository` | +| Show the detailed architecture view | `explain-codebase . --verbose` | +| Focus on architecture risks | `explain-codebase . --deep` | +| Write JSON to stdout | `explain-codebase . --json` | +| Limit the number of scanned files | `explain-codebase . --max-files 500` | +| Suggest a reading order | `explain-codebase onboarding .` | +| Explain one file in repository context | `explain-codebase file src/services/orders.py` | +| Write an interactive dependency graph | `explain-codebase . --graph` | +| Write an HTML architecture report | `explain-codebase . --report` | +| Return a failing status for detected architecture issues | `explain-codebase . --ci` | +| Show the installed version | `explain-codebase --version` | -Generate an interactive dependency graph: +`--verbose` and `--deep` cannot be combined. -```bash -explain-codebase . --graph -``` +### Graph views -Generate a full HTML architecture report: +`--graph` writes `dependency_graph.html`. `--report` writes `codebase_report.html`. Both files are written to the current working directory. -```bash -explain-codebase . --report -``` +| Flag | View | +| --- | --- | +| `--architecture` | Architecture-level relationships; this is the default graph view | +| `--full` | Full file-level dependency graph | +| `--entrypoint` | Paths starting from likely entry points | +| `--risk` | Highly connected and risky files | +| `--side-effects` | Files with probable external side effects | -### CI mode +Choose at most one graph-view flag. A graph-view flag requires `--graph` or `--report`. -Use this in CI when you want architecture issues to fail the build: +Examples: ```bash -explain-codebase . --ci +explain-codebase . --graph --architecture +explain-codebase . --graph --entrypoint +explain-codebase . --report --risk +explain-codebase . --graph --full ``` -## Example Output +## Supported source formats -### Default output +| Language | Extensions | Primary signals | +| --- | --- | --- | +| Python | `.py` | syntax tree, imports, definitions, calls, and side effects | +| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` | static imports, CommonJS imports, calls, and side effects | +| TypeScript | `.ts`, `.tsx`, `.mts`, `.cts` | static imports, calls, and side effects | -Default output is intentionally compact: +Import resolution accounts for relative paths, package entry files, and the supported extension variants where those relationships can be determined statically. -```text -Explain Codebase --------------------------------- - -Repository - - Path C:\Projects\checkout-service - Type Python backend service - Language python - Files 7 - -Architecture - - Entrypoints 1 - Core modules 5 - Side effects 4 +## Scanning behavior -Suggested starting point +For a local directory, the scanner: - api_server.py +- considers only the supported source extensions +- keeps resolved file paths inside the selected repository +- honors the optional file-count limit and a 1 MiB per-file size limit +- skips common dependency, cache, build, coverage, and environment directories +- respects the root `.gitignore` file +- limits a Git worktree to tracked files when Git metadata can be read, then falls back to filesystem scanning if Git is unavailable +- handles unreadable source files safely and skips unsupported or oversized files -Run with --verbose to see full architecture -``` +Use `--max-files` to lower the file-count limit for a focused or faster scan. -### Verbose output +## Public GitHub repositories -Verbose mode adds more structure, including a likely execution path: +Pass a public repository URL in the canonical form: -```text -Execution flow - -api_server.py -|- routes/order_routes.py -|- services/order_service.py -| |- repositories/order_repository.py -| \- clients/warehouse_client.py -\- middleware/auth_guard.py +```bash +explain-codebase https://github.com/owner/repository ``` -This output is heuristic. It reflects likely structure based on static signals such as imports, naming conventions, and folder layout. It should be treated as a high-value map, not as guaranteed truth. +The CLI checks public repository metadata, asks for confirmation, performs a limited clone in a temporary directory, analyzes the clone, and removes the temporary directory afterward. Private repositories, other hosting providers, and arbitrary Git URLs are not supported. -## Features +Remote inspection requires an interactive terminal, Git, and network access. Clone timeouts can stop remote preparation, and oversized source files are skipped during analysis. -- analyzes local folders and public GitHub repositories -- detects project language and project type -- attempts to detect likely entrypoints automatically -- ranks central modules by dependency usage -- surfaces likely execution paths -- highlights modules that interact with database, network, filesystem, or cache -- detects common architecture folders such as `services`, `repositories`, `routes`, and `models` -- flags large modules and highly coupled files -- highlights potential architecture issues such as circular dependencies -- generates dependency graph visualizations -- generates HTML architecture reports -- explains a single file in project context -- suggests onboarding reading paths -- supports CI mode for architecture checks +## Output and automation -## Remote Repositories +### Standard output and error output -You can analyze a public GitHub repository directly: +Human-readable output and JSON are written to stdout. Progress stages, warnings, and errors are written to stderr. This keeps JSON suitable for redirection: ```bash -explain-codebase https://github.com/user/repo +explain-codebase . --json > architecture.json ``` -For remote repositories, the tool: - -- supports public GitHub repository URLs -- clones the repository into a temporary workspace -- cleans up that workspace after analysis +### CI mode -## How It Works +```bash +explain-codebase . --ci +``` -At a high level, the tool: +CI mode exits with status `0` when no architecture issues are found and status `1` when an issue is detected. Current issue checks include circular dependencies and utility-style god modules. Thresholds are built into the CLI. -- scans source files in the target repository -- detects language and likely project type -- parses imports and builds a dependency graph -- scores central modules using graph signals -- surfaces likely entrypoints, side effects, hotspots, and onboarding hints -- renders the result in CLI, JSON, and optional HTML outputs +### JSON -## Limitations +JSON output includes repository information, entry points, central modules, side-effect files, architecture areas, large files, hotspots, risky files, architecture issues, execution paths, and paths to optional HTML outputs. -- the analysis is heuristic, not full semantic understanding -- best results come from Python, JavaScript, and TypeScript projects with conventional layouts -- dynamic imports, reflection-heavy code, and runtime dependency injection may reduce accuracy -- generated, vendored, or mirrored code can reduce signal quality -- large monorepos may need path scoping or `--max-files` to keep output focused +## How it works -## CI Behavior +At a high level, `explain-codebase`: -CI mode is intended for lightweight architectural checks: +1. resolves and validates the target +2. selects supported source files within safety boundaries +3. parses static imports and source-level signals +4. resolves imports and builds a dependency graph +5. ranks central modules and identifies entry points, side effects, hotspots, and architecture issues +6. renders the selected CLI, JSON, graph, or report output -```bash -explain-codebase . --ci -``` +The target project's source code is not imported or executed during analysis. -Current behavior: +## Limitations -- exit code `0` when no architecture issues are detected -- exit code `1` when architecture issues are found -- current issue types include circular dependencies and utility-style god modules -- thresholds are currently built into the tool and are not yet configurable through CLI flags +- Dynamic imports, reflection, runtime dependency injection, and framework-specific wiring may not be visible. +- Custom path aliases and build-tool transformations may reduce import-resolution accuracy. +- Syntax that is valid only after a separate transform step may be skipped. +- Minified, vendored, mirrored, or highly repetitive code can reduce signal quality. +- Large monorepos should use `--max-files` or analyze a narrower directory. +- Remote analysis supports only public GitHub repositories. +- A shallow remote clone can still transfer large files because total repository download size is not capped. +- Interactive HTML views load a version-pinned, integrity-checked graph library from a public CDN and need network access when opened. -## JSON Output +## Development -Use JSON output when you want to integrate the tool into scripts or pipelines: +Install the development tools and run the checks: ```bash -explain-codebase . --json +python -m pip install -e ".[dev]" +ruff check . +pytest +python -m build +python -m twine check --strict dist/* ``` -The JSON output includes fields such as: +## Project information -- `project_type` -- `entrypoints` -- `core_modules` -- `core_module_rankings` -- `side_effect_modules` -- `architecture_modules` -- `large_files` -- `hotspots` -- `dangerous_files` -- `architecture_issues` -- `execution_flow` -- `dependency_graph_output` -- `html_report_output` +- [Changelog](https://github.com/danyasync/explain-codebase/blob/main/CHANGELOG.md) +- [Security policy](https://github.com/danyasync/explain-codebase/blob/main/SECURITY.md) +- [Issue tracker](https://github.com/danyasync/explain-codebase/issues) +- [PyPI package](https://pypi.org/project/explain-codebase/) -## Tests +## License -```bash -pytest -``` +Licensed under the [MIT License](https://github.com/danyasync/explain-codebase/blob/main/LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fa324e1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported versions + +Security updates are provided for the current minor release line. + +| Version | Supported | +| --- | --- | +| 0.2.x | Yes | +| Earlier releases | No | + +## Reporting a vulnerability + +Please report suspected vulnerabilities privately through [GitHub Security Advisories](https://github.com/danyasync/explain-codebase/security/advisories/new). + +Include the affected version, operating system, Python version, reproduction steps, expected impact, and any known workaround. Do not include sensitive details in a public issue or discussion. + +Please allow time to investigate and coordinate a fix before public disclosure. Once a fix is available, release notes will describe the affected versions and the recommended upgrade. diff --git a/explain_codebase/analysis/hotspot_detector.py b/explain_codebase/analysis/hotspot_detector.py index 69716cc..2bf6346 100644 --- a/explain_codebase/analysis/hotspot_detector.py +++ b/explain_codebase/analysis/hotspot_detector.py @@ -15,6 +15,7 @@ def detect(self, graph: nx.DiGraph, limit: int = 5) -> list[HotspotRecord]: coupling_score=graph.in_degree(node) + graph.out_degree(node), ) for node in graph.nodes + if graph.degree(node) > 0 ] hotspots.sort(key=lambda item: (-item.coupling_score, -item.incoming_imports, item.path)) return hotspots[:limit] diff --git a/explain_codebase/cli/main.py b/explain_codebase/cli/main.py index 17aa8c5..ce4ab0d 100644 --- a/explain_codebase/cli/main.py +++ b/explain_codebase/cli/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from importlib.metadata import PackageNotFoundError, version from pathlib import Path import click @@ -32,6 +33,8 @@ from explain_codebase.renderers.html_report_renderer import HtmlReportRenderer from explain_codebase.renderers.json_renderer import JsonRenderer from explain_codebase.scanner.project_scanner import ProjectScanner +from explain_codebase.utils.output_utils import terminal_safe_text + class PlainHelpCommand(click.Command): def get_help(self, ctx: click.Context) -> str: @@ -58,6 +61,7 @@ def get_help(self, ctx: click.Context) -> str: " --report Generate codebase_report.html", " --ci Exit with code 1 when architecture issues are detected", " --max-files N Limit scanned source files", + " --version Show the installed version and exit", " -h, --help Show this message and exit", "", "Examples", @@ -140,7 +144,7 @@ def scan_project(self, target: Path, max_files: int | None = None) -> ProjectInf root_path=target, files=parsed_files, languages=sorted(languages), - project_type=self.project_type_detector.detect(target, sorted(languages)), + project_type=self.project_type_detector.detect(target, sorted(languages), parsed_files), ) def build_dependency_graph(self, project_info: ProjectInfo): @@ -206,14 +210,14 @@ def build_onboarding_path(self, target: Path, max_files: int | None = None) -> t def guess_project_root(self, target_file: Path) -> Path: resolved_target = target_file.resolve() - ancestors = [resolved_target.parent, *resolved_target.parents] + ancestors = list(resolved_target.parents) for parent in ancestors: - if self._looks_like_project_root(parent): + if any((parent / marker).exists() for marker in self.ROOT_MARKERS): return parent for parent in ancestors: - if any((parent / marker).exists() for marker in self.ROOT_MARKERS): + if self._looks_like_project_root(parent): return parent return resolved_target.parent @@ -292,17 +296,25 @@ def main( ) +def _installed_version() -> str: + try: + return version("explain-codebase") + except PackageNotFoundError: + return "unknown" + + @click.command( name="explain-codebase", cls=PlainHelpCommand, context_settings={"help_option_names": ["-h", "--help"]}, ) +@click.version_option(version=_installed_version(), prog_name="explain-codebase") @click.argument("target", required=False, default=".") @click.argument("extra_args", nargs=-1) @click.option("--json", "json_output", is_flag=True, help="Output analysis as JSON") @click.option("--verbose", is_flag=True, help="Show full architecture output") @click.option("--deep", is_flag=True, help="Show architecture issues") -@click.option("--max-files", type=int, default=None, metavar="N", help="Limit scanned source files") +@click.option("--max-files", type=click.IntRange(min=1), default=None, metavar="N", help="Limit scanned source files") @click.option("--graph", is_flag=True, help="Generate dependency_graph.html") @click.option("--full", "graph_full", is_flag=True, help="Render the full file-level graph") @click.option("--architecture", "graph_architecture", is_flag=True, help="Render architecture view graph") @@ -352,22 +364,22 @@ def run(argv: list[str] | None = None) -> None: option_name = exc.option_name if not option_name.startswith("--"): option_name = f"--{option_name.lstrip('-')}" - click.echo(f"Error: Unknown option {option_name}", err=True) + click.echo(f"Error: Unknown option {terminal_safe_text(option_name)}", err=True) possibilities = getattr(exc, "possibilities", None) or [] if possibilities: suggestion = possibilities[0] if not suggestion.startswith("--"): suggestion = f"--{suggestion.lstrip('-')}" - click.echo(f"Did you mean {suggestion}?", err=True) - raise SystemExit(exc.exit_code) + click.echo(f"Did you mean {terminal_safe_text(suggestion)}?", err=True) + raise SystemExit(exc.exit_code) from None except click.ClickException as exc: - click.echo(f"Error: {exc.format_message()}", err=True) - raise SystemExit(exc.exit_code) + click.echo(f"Error: {terminal_safe_text(exc.format_message())}", err=True) + raise SystemExit(exc.exit_code) from None except typer.BadParameter as exc: - click.echo(f"Error: {exc}", err=True) - raise SystemExit(2) + click.echo(f"Error: {terminal_safe_text(exc)}", err=True) + raise SystemExit(2) from None except typer.Exit as exc: - raise SystemExit(exc.exit_code) + raise SystemExit(exc.exit_code) from None def _run_project_analysis( @@ -427,7 +439,7 @@ def _run_project_analysis( if ci and result.architecture_issues: typer.echo("Architecture warnings detected:", err=True) for issue in result.architecture_issues: - typer.echo(issue.description, err=True) + typer.echo(terminal_safe_text(issue.description), err=True) raise typer.Exit(code=1) finally: if resolved is not None: @@ -547,13 +559,13 @@ def _generate_optional_outputs( graph_path = output_root / "dependency_graph.html" GraphRenderer().render(result, dependency_graph, graph_path, options=graph_options) result.dependency_graph_output = str(graph_path.resolve()) - typer.echo(f"Dependency graph written to {graph_path.resolve()}", err=True) + typer.echo(f"Dependency graph written to {terminal_safe_text(graph_path.resolve())}", err=True) if report: report_path = output_root / "codebase_report.html" HtmlReportRenderer().render(result, dependency_graph, report_path, graph_options=graph_options) result.html_report_output = str(report_path.resolve()) - typer.echo(f"HTML report written to {report_path.resolve()}", err=True) + typer.echo(f"HTML report written to {terminal_safe_text(report_path.resolve())}", err=True) if __name__ == "__main__": diff --git a/explain_codebase/cli/target_resolution.py b/explain_codebase/cli/target_resolution.py index 33f2e45..fc3e243 100644 --- a/explain_codebase/cli/target_resolution.py +++ b/explain_codebase/cli/target_resolution.py @@ -9,8 +9,12 @@ from urllib.parse import urlparse from urllib.request import Request, urlopen +import click import typer +from explain_codebase.utils.git_utils import hardened_git_runtime +from explain_codebase.utils.output_utils import terminal_safe_text + @dataclass class ResolvedTarget: @@ -31,6 +35,7 @@ class GitHubRepository: class TargetResolver: TOTAL_STEPS = 5 + CLONE_TIMEOUT_SECONDS = 120 def resolve(self, target: str) -> ResolvedTarget: normalized_target = target.strip() or "." @@ -77,15 +82,26 @@ def _resolve_remote_target(self, repository: GitHubRepository) -> ResolvedTarget temp_dir = Path(tempfile.mkdtemp(prefix="explain_codebase_")).resolve() typer.echo("Temporary workspace", err=True) - typer.echo(f" {temp_dir}", err=True) + typer.echo(f" {terminal_safe_text(temp_dir)}", err=True) typer.echo("", err=True) try: self._print_stage(2, "Cloning repository...") self._clone_repository(repository.clone_url, temp_dir) - except Exception: + except FileNotFoundError: + shutil.rmtree(temp_dir, ignore_errors=True) + raise click.ClickException("Git is required to analyze a remote repository.") from None + except subprocess.TimeoutExpired: + shutil.rmtree(temp_dir, ignore_errors=True) + raise click.ClickException( + f"Repository clone exceeded the {self.CLONE_TIMEOUT_SECONDS}-second time limit." + ) from None + except subprocess.CalledProcessError: shutil.rmtree(temp_dir, ignore_errors=True) - raise + raise click.ClickException("Git could not clone the requested public repository.") from None + except OSError as error: + shutil.rmtree(temp_dir, ignore_errors=True) + raise click.ClickException(f"Could not prepare the remote repository: {error}") from None return ResolvedTarget( analysis_path=temp_dir, @@ -95,15 +111,33 @@ def _resolve_remote_target(self, repository: GitHubRepository) -> ResolvedTarget ) def _clone_repository(self, target: str, destination: Path) -> None: - subprocess.run( - ["git", "clone", target, str(destination)], - check=True, - ) + with hardened_git_runtime() as (command_prefix, environment): + subprocess.run( + [ + *command_prefix, + "-c", + "credential.helper=", + "clone", + "--depth=1", + "--single-branch", + "--no-tags", + "--", + target, + str(destination), + ], + check=True, + env=environment, + timeout=self.CLONE_TIMEOUT_SECONDS, + ) def _ask_yes_no(self, prompt: str) -> bool: while True: typer.echo(prompt, nl=False, err=True) - answer = input().strip().lower() + try: + answer = input().strip().lower() + except EOFError: + typer.echo("Interactive confirmation is required for remote repositories.", err=True) + raise typer.Exit(code=2) from None if answer in {"y", "yes"}: return True if answer in {"n", "no"}: @@ -146,7 +180,8 @@ def _check_repository_access(self, repository: GitHubRepository) -> str: }, ) try: - with urlopen(request, timeout=10) as response: + # repository.api_url is synthesized from a strict https://github.com/{owner}/{repo} target. + with urlopen(request, timeout=10) as response: # nosec B310 if response.status == 200: return "exists" except HTTPError as error: @@ -162,12 +197,12 @@ def _check_repository_access(self, repository: GitHubRepository) -> str: def _print_remote_not_found(self, target: str) -> None: typer.echo("Error: Repository not found", err=True) - typer.echo(target, err=True) + typer.echo(terminal_safe_text(target), err=True) typer.echo("Make sure the repository exists and is publicly accessible.", err=True) def _print_remote_not_accessible(self, target: str) -> None: typer.echo("Error: Repository is not accessible", err=True) - typer.echo(target, err=True) + typer.echo(terminal_safe_text(target), err=True) typer.echo("Only public repositories are supported.", err=True) def _print_stage(self, step: int, message: str) -> None: @@ -175,5 +210,5 @@ def _print_stage(self, step: int, message: str) -> None: def _print_repository_reference(self, target: str) -> None: typer.echo("Repository", err=True) - typer.echo(f" {target}", err=True) + typer.echo(f" {terminal_safe_text(target)}", err=True) typer.echo("", err=True) diff --git a/explain_codebase/detectors/language_detector.py b/explain_codebase/detectors/language_detector.py index ce261b4..9969c4c 100644 --- a/explain_codebase/detectors/language_detector.py +++ b/explain_codebase/detectors/language_detector.py @@ -7,7 +7,13 @@ class LanguageDetector: EXTENSION_TO_LANGUAGE = { ".py": "python", ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", ".ts": "typescript", + ".tsx": "typescript", + ".mts": "typescript", + ".cts": "typescript", } def detect(self, path: Path) -> str: diff --git a/explain_codebase/detectors/project_type_detector.py b/explain_codebase/detectors/project_type_detector.py index b4bee0a..bb8d349 100644 --- a/explain_codebase/detectors/project_type_detector.py +++ b/explain_codebase/detectors/project_type_detector.py @@ -3,33 +3,33 @@ import json from pathlib import Path +from explain_codebase.models.file_info import FileInfo +from explain_codebase.utils.file_utils import safe_read_text + class ProjectTypeDetector: - def detect(self, root_path: Path, languages: list[str]) -> str: + PYTHON_CLI_MODULES = {"argparse", "click", "typer"} + PYTHON_BACKEND_MODULES = {"django", "fastapi", "flask", "sqlalchemy"} + + def detect(self, root_path: Path, languages: list[str], files: list[FileInfo] | None = None) -> str: + files = files or [] package_json = root_path / "package.json" - pyproject_toml = root_path / "pyproject.toml" - requirements = root_path / "requirements.txt" if "python" in languages: - if self._is_python_cli(root_path): + if self._is_python_cli(files): return "Python CLI tool" - if ( - pyproject_toml.exists() - or requirements.exists() - or (root_path / "manage.py").exists() - or (root_path / "app.py").exists() - or (root_path / "main.py").exists() - or self._has_python_backend_signals(root_path) - ): + if self._has_python_backend_signals(files): return "Python backend service" if "javascript" in languages or "typescript" in languages: if package_json.exists(): package_data = self._read_package_json(package_json) - deps = " ".join( - list(package_data.get("dependencies", {}).keys()) - + list(package_data.get("devDependencies", {}).keys()) - ).lower() + dependency_names: list[str] = [] + for section_name in ("dependencies", "devDependencies"): + section = package_data.get(section_name) + if isinstance(section, dict): + dependency_names.extend(str(name) for name in section) + deps = " ".join(dependency_names).lower() if any(signal in deps for signal in ["react", "next", "vite"]): return "Frontend application" if any(signal in deps for signal in ["express", "fastify", "nestjs"]): @@ -40,30 +40,30 @@ def detect(self, root_path: Path, languages: list[str]) -> str: return "Unknown project" - def _is_python_cli(self, root_path: Path) -> bool: - for path in root_path.rglob("*.py"): - try: - content = path.read_text(encoding="utf-8") - except UnicodeDecodeError: - content = path.read_text(encoding="utf-8", errors="ignore") - lowered = content.lower() - if any(signal in lowered for signal in ["import typer", "import click", "import argparse"]): + def _is_python_cli(self, files: list[FileInfo]) -> bool: + for file in files: + if file.has_cli_signal: + return True + imported_roots = {module.lstrip(".").split(".", 1)[0].lower() for module in file.imports} + if file.role == "entrypoint" and imported_roots.intersection(self.PYTHON_CLI_MODULES): return True return False - def _read_package_json(self, path: Path) -> dict: + def _read_package_json(self, path: Path) -> dict[str, object]: + content = safe_read_text(path, root_path=path.parent) + if not content: + return {} try: - return json.loads(path.read_text(encoding="utf-8")) - except Exception: + data = json.loads(content) + except json.JSONDecodeError: return {} + return data if isinstance(data, dict) else {} - def _has_python_backend_signals(self, root_path: Path) -> bool: - for path in root_path.rglob("*.py"): - try: - content = path.read_text(encoding="utf-8") - except UnicodeDecodeError: - content = path.read_text(encoding="utf-8", errors="ignore") - lowered = content.lower() - if any(signal in lowered for signal in ["fastapi", "flask", "django", "sqlalchemy", "app = "]): + def _has_python_backend_signals(self, files: list[FileInfo]) -> bool: + for file in files: + imported_roots = {module.lstrip(".").split(".", 1)[0].lower() for module in file.imports} + if imported_roots.intersection(self.PYTHON_BACKEND_MODULES): + return True + if file.route_handlers: return True return False diff --git a/explain_codebase/graph/dependency_graph.py b/explain_codebase/graph/dependency_graph.py index 1cd72de..7e4d1da 100644 --- a/explain_codebase/graph/dependency_graph.py +++ b/explain_codebase/graph/dependency_graph.py @@ -1,6 +1,6 @@ from __future__ import annotations -from pathlib import Path +from pathlib import PurePosixPath import networkx as nx @@ -8,6 +8,20 @@ class DependencyGraphBuilder: + MODULE_EXTENSIONS = (".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts") + INDEX_FILES = ( + "__init__.py", + "index.js", + "index.jsx", + "index.mjs", + "index.cjs", + "index.ts", + "index.tsx", + "index.mts", + "index.cts", + ) + JAVASCRIPT_EXTENSIONS = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"} + def build(self, files: list[FileInfo]) -> nx.DiGraph: graph = nx.DiGraph() path_map = {file.path: file for file in files} @@ -26,7 +40,7 @@ def build(self, files: list[FileInfo]) -> nx.DiGraph: def _build_module_index(self, files: list[FileInfo]) -> dict[str, str]: index: dict[str, str] = {} for file in files: - path = Path(file.path) + path = PurePosixPath(file.path) parts = list(path.with_suffix("").parts) dotted = ".".join(parts) index[dotted] = file.path @@ -41,7 +55,7 @@ def _resolve_import( module_index: dict[str, str], path_map: dict[str, FileInfo], ) -> str | None: - source = Path(source_path) + source = PurePosixPath(source_path) if imported.startswith("."): return self._resolve_relative_import(source, imported, path_map) @@ -50,45 +64,64 @@ def _resolve_import( return module_index[normalized] candidate = imported.replace(".", "/") - for extension in [".py", ".js", ".ts"]: + for extension in self.MODULE_EXTENSIONS: file_candidate = f"{candidate}{extension}" if file_candidate in path_map: return file_candidate - for extension in ["/__init__.py", "/index.js", "/index.ts"]: - file_candidate = f"{candidate}{extension}" + for index_file in self.INDEX_FILES: + file_candidate = f"{candidate}/{index_file}" if file_candidate in path_map: return file_candidate return None def _resolve_relative_import( self, - source: Path, + source: PurePosixPath, imported: str, path_map: dict[str, FileInfo], ) -> str | None: + if source.suffix.lower() in self.JAVASCRIPT_EXTENSIONS: + target_base = self._resolve_javascript_relative_base(source, imported) + else: + target_base = self._resolve_python_relative_base(source, imported) + if target_base is None: + return None + + candidates = [target_base.as_posix()] + candidates.extend(f"{target_base.as_posix()}{extension}" for extension in self.MODULE_EXTENSIONS) + candidates.extend((target_base / index_file).as_posix() for index_file in self.INDEX_FILES) + for candidate in candidates: + if candidate in path_map: + return candidate + return None + + def _resolve_python_relative_base(self, source: PurePosixPath, imported: str) -> PurePosixPath | None: dots = len(imported) - len(imported.lstrip(".")) remainder = imported.lstrip(".") - base = source.parent + base_parts = list(source.parent.parts) for _ in range(max(dots - 1, 0)): - base = base.parent + if len(base_parts) <= 1: + return None + base_parts.pop() if "/" in remainder or "\\" in remainder: cleaned = remainder.lstrip("/\\") - relative_parts = [part for part in Path(cleaned).parts if part not in {".", ""}] + relative_parts = [part for part in PurePosixPath(cleaned.replace("\\", "/")).parts if part not in {".", ""}] else: relative_parts = [part for part in remainder.split(".") if part] - target_base = base.joinpath(*relative_parts) if relative_parts else base - - candidates = [ - target_base.with_suffix(".py"), - target_base.with_suffix(".js"), - target_base.with_suffix(".ts"), - target_base / "__init__.py", - target_base / "index.js", - target_base / "index.ts", - ] - for candidate in candidates: - candidate_str = candidate.as_posix() - if candidate_str in path_map: - return candidate_str - return None + base = PurePosixPath(*base_parts) + return base.joinpath(*relative_parts) if relative_parts else base + + def _resolve_javascript_relative_base(self, source: PurePosixPath, imported: str) -> PurePosixPath | None: + parts = list(source.parent.parts) + for part in PurePosixPath(imported.replace("\\", "/")).parts: + if part in {"", "."}: + continue + if part == "..": + if parts: + parts.pop() + else: + return None + continue + parts.append(part) + return PurePosixPath(*parts) diff --git a/explain_codebase/models/file_info.py b/explain_codebase/models/file_info.py index 6693cad..967d2a1 100644 --- a/explain_codebase/models/file_info.py +++ b/explain_codebase/models/file_info.py @@ -4,7 +4,6 @@ from pydantic import BaseModel, Field - Language = Literal["python", "javascript", "typescript", "unknown"] diff --git a/explain_codebase/parsers/js_parser.py b/explain_codebase/parsers/js_parser.py index 7d7c704..73f9b26 100644 --- a/explain_codebase/parsers/js_parser.py +++ b/explain_codebase/parsers/js_parser.py @@ -6,9 +6,13 @@ from explain_codebase.models.file_info import FileInfo from explain_codebase.utils.file_utils import safe_read_text - -IMPORT_RE = re.compile(r"""import\s+(?:.+?\s+from\s+)?["'](.+?)["']|require\(["'](.+?)["']\)""") -FUNCTION_RE = re.compile(r"""(?:function\s+([A-Za-z_]\w*)|\bconst\s+([A-Za-z_]\w*)\s*=\s*\(?[^=]*?\)?\s*=>)""") +FROM_SOURCE_RE = re.compile(r"""\bfrom[ \t]+(?:"([^"\r\n]+)"|'([^'\r\n]+)')""") +REQUIRE_RE = re.compile( + r"""\brequire[ \t]*\([ \t]*(?:"([^"\r\n]+)"|'([^'\r\n]+)')[ \t]*\)""" +) +FUNCTION_DECLARATION_RE = re.compile(r"""\bfunction[ \t]+([A-Za-z_]\w*)""") +CONST_ASSIGNMENT_RE = re.compile(r"""\bconst[ \t]+([A-Za-z_]\w*)[ \t]*=""") +IDENTIFIER_RE = re.compile(r"""[A-Za-z_]\w*""") CLASS_RE = re.compile(r"""\bclass\s+([A-Za-z_]\w*)""") CALL_RE = re.compile(r"""\b([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*\(""") ROUTE_RE = re.compile(r"""\b(?:app|router)\.(get|post|put|delete|patch|use)\s*\(""") @@ -33,7 +37,7 @@ class JavaScriptParser: def parse(self, path: Path, root_path: Path) -> FileInfo: - content = safe_read_text(path) + content = safe_read_text(path, root_path=root_path) relative_path = path.relative_to(root_path).as_posix() info = FileInfo( path=relative_path, @@ -41,15 +45,11 @@ def parse(self, path: Path, root_path: Path) -> FileInfo: line_count=len(content.splitlines()), ) - for first, second in IMPORT_RE.findall(content): - imported = first or second + for imported in self._find_imports(content): info.imports.append(imported) self._register_side_effect_import(info, imported) - for match in FUNCTION_RE.findall(content): - name = match[0] or match[1] - if name: - info.functions.append(name) + info.functions.extend(self._find_functions(content)) info.classes.extend(CLASS_RE.findall(content)) info.function_calls.extend(CALL_RE.findall(content)) @@ -78,6 +78,150 @@ def parse(self, path: Path, root_path: Path) -> FileInfo: self._add_side_effect(info, category) return info + def _find_imports(self, content: str) -> list[str]: + imports: list[str] = [] + for line in content.splitlines(): + matches: list[tuple[int, str]] = [] + stripped = line.lstrip(" \t") + if stripped.startswith("import") and len(stripped) > len("import"): + separator = stripped[len("import")] + if separator in " \t": + remainder = stripped[len("import") :].lstrip(" \t") + imported = self._static_import_source(remainder) + if imported is not None: + matches.append((len(line) - len(stripped), imported)) + + for match in REQUIRE_RE.finditer(line): + matches.append((match.start(), match.group(1) or match.group(2))) + + imports.extend(imported for _, imported in sorted(matches, key=lambda item: item[0])) + return imports + + def _static_import_source(self, remainder: str) -> str | None: + if not remainder: + return None + if remainder[0] in {'"', "'"}: + quote = remainder[0] + end = remainder.find(quote, 1) + return remainder[1:end] if end > 1 else None + + match = FROM_SOURCE_RE.search(remainder) + if match is None: + return None + return match.group(1) or match.group(2) + + def _find_functions(self, content: str) -> list[str]: + matches = [(match.start(), match.group(1)) for match in FUNCTION_DECLARATION_RE.finditer(content)] + for match in CONST_ASSIGNMENT_RE.finditer(content): + if self._has_arrow_signature(content, match.end()): + matches.append((match.start(), match.group(1))) + return [name for _, name in sorted(matches, key=lambda item: item[0])] + + def _has_arrow_signature(self, content: str, start: int) -> bool: + position = self._skip_whitespace(content, start) + async_match = IDENTIFIER_RE.match(content, position) + if async_match is not None and async_match.group(0) == "async": + after_async = self._skip_whitespace(content, async_match.end()) + if not content.startswith("=>", after_async): + if after_async >= len(content): + return False + if after_async == async_match.end() and content[after_async] != "(": + return False + position = after_async + + identifier_match = IDENTIFIER_RE.match(content, position) + if identifier_match is not None: + position = identifier_match.end() + elif position < len(content) and content[position] == "(": + closing_position = self._scan_parenthesized(content, position) + if closing_position is None: + return False + position = closing_position + else: + return False + + position = self._skip_whitespace(content, position) + if content.startswith("=>", position): + return True + if position >= len(content) or content[position] != ":": + return False + return self._scan_return_type_for_arrow(content, position + 1) + + def _scan_parenthesized(self, content: str, start: int) -> int | None: + depth = 0 + quote: str | None = None + escaped = False + position = start + while position < len(content): + character = content[position] + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + elif character in {'"', "'", "`"}: + quote = character + elif character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth == 0: + return position + 1 + elif character == ";" or self._keyword_at(content, position, "const"): + return None + position += 1 + return None + + def _scan_return_type_for_arrow(self, content: str, start: int) -> bool: + opening = {"(": ")", "[": "]", "{": "}"} + closing = set(opening.values()) + stack: list[str] = [] + quote: str | None = None + escaped = False + position = start + while position < len(content): + character = content[position] + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + position += 1 + continue + if character in {'"', "'", "`"}: + quote = character + elif not stack and content.startswith("=>", position): + return True + elif character in opening: + stack.append(opening[character]) + elif character in closing: + if not stack or stack.pop() != character: + return False + elif not stack and (character == ";" or self._keyword_at(content, position, "const")): + return False + position += 1 + return False + + def _skip_whitespace(self, content: str, start: int) -> int: + position = start + while position < len(content) and content[position].isspace(): + position += 1 + return position + + def _keyword_at(self, content: str, position: int, keyword: str) -> bool: + if not content.startswith(keyword, position): + return False + previous = content[position - 1] if position > 0 else "" + next_position = position + len(keyword) + following = content[next_position] if next_position < len(content) else "" + return (not previous or not (previous.isalnum() or previous == "_")) and ( + not following or not (following.isalnum() or following == "_") + ) + def _register_side_effect_import(self, info: FileInfo, module_name: str) -> None: cleaned_name = module_name.lower().lstrip("./") root_module = cleaned_name.split("/")[0] diff --git a/explain_codebase/parsers/python_parser.py b/explain_codebase/parsers/python_parser.py index 3e23bf7..be53752 100644 --- a/explain_codebase/parsers/python_parser.py +++ b/explain_codebase/parsers/python_parser.py @@ -6,7 +6,6 @@ from explain_codebase.models.file_info import FileInfo from explain_codebase.utils.file_utils import safe_read_text - SIDE_EFFECT_IMPORT_CATEGORIES = { "aiohttp": "network", "asyncpg": "database", @@ -14,17 +13,13 @@ "httpx": "network", "motor": "database", "mysql": "database", - "os": "filesystem", - "pathlib": "filesystem", "psycopg": "database", "psycopg2": "database", "pymongo": "database", "redis": "cache", "requests": "network", - "shutil": "filesystem", "sqlalchemy": "database", "sqlite3": "database", - "tempfile": "filesystem", "urllib": "network", } @@ -46,10 +41,45 @@ "write_text": "filesystem", } +FILESYSTEM_CALL_SUFFIXES = { + ".open", + ".read_bytes", + ".read_text", + ".write_bytes", + ".write_text", +} + +FILESYSTEM_CALL_NAMES = { + "os.mkdir", + "os.makedirs", + "os.remove", + "os.rename", + "os.replace", + "os.rmdir", + "os.scandir", + "os.unlink", + "os.walk", +} + +ROUTE_DECORATOR_NAMES = { + "api_route", + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "route", + "trace", + "websocket", + "websocket_route", +} + class PythonParser: def parse(self, path: Path, root_path: Path) -> FileInfo: - content = safe_read_text(path) + content = safe_read_text(path, root_path=root_path) relative_path = path.relative_to(root_path).as_posix() info = FileInfo( path=relative_path, @@ -69,21 +99,20 @@ def parse(self, path: Path, root_path: Path) -> FileInfo: self._register_side_effect_import(info, alias.name) elif isinstance(node, ast.ImportFrom): module = node.module or "" - if node.level: - info.imports.append("." * node.level + module) - elif module: - info.imports.append(module) + import_base = "." * node.level + module + if import_base: + info.imports.append(import_base) + if not node.level and module: self._register_side_effect_import(info, module) - elif isinstance(node, ast.FunctionDef): - info.functions.append(node.name) - for decorator in node.decorator_list: - decorator_name = self._expr_name(decorator) - if decorator_name: - info.decorators.append(decorator_name) - if any(signal in decorator_name.lower() for signal in ["get", "post", "put", "delete", "route"]): - info.route_handlers.append(node.name) - elif isinstance(node, ast.AsyncFunctionDef): - info.functions.append(node.name) + for alias in node.names: + if alias.name == "*": + continue + separator = "" if import_base.endswith(".") else "." + imported_member = f"{import_base}{separator}{alias.name}" if import_base else alias.name + if imported_member != import_base: + info.imports.append(imported_member) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + self._register_function(info, node) elif isinstance(node, ast.ClassDef): info.classes.append(node.name) elif isinstance(node, ast.Call): @@ -96,9 +125,8 @@ def parse(self, path: Path, root_path: Path) -> FileInfo: if any(signal in lowered for signal in ["typer.run", "click.command", "argparse"]): info.has_cli_signal = True self._register_side_effect_call(info, lowered) - elif isinstance(node, ast.If): - if self._is_main_guard(node): - info.has_main_guard = True + elif isinstance(node, ast.If) and self._is_main_guard(node): + info.has_main_guard = True return info @@ -112,6 +140,16 @@ def _expr_name(self, node: ast.AST) -> str | None: return self._expr_name(node.func) return None + def _register_function(self, info: FileInfo, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + info.functions.append(node.name) + for decorator in node.decorator_list: + decorator_name = self._expr_name(decorator) + if not decorator_name: + continue + info.decorators.append(decorator_name) + if decorator_name.rsplit(".", 1)[-1].lower() in ROUTE_DECORATOR_NAMES: + info.route_handlers.append(node.name) + def _is_main_guard(self, node: ast.If) -> bool: test = node.test if not isinstance(test, ast.Compare): @@ -130,8 +168,10 @@ def _register_side_effect_import(self, info: FileInfo, module_name: str) -> None self._add_side_effect(info, category) def _register_side_effect_call(self, info: FileInfo, call_name: str) -> None: + if call_name in FILESYSTEM_CALL_NAMES or any(call_name.endswith(suffix) for suffix in FILESYSTEM_CALL_SUFFIXES): + self._add_side_effect(info, "filesystem") for prefix, category in SIDE_EFFECT_CALL_PREFIXES.items(): - if call_name == prefix or call_name.startswith(prefix): + if call_name == prefix or (prefix.endswith(".") and call_name.startswith(prefix)): self._add_side_effect(info, category) def _add_side_effect(self, info: FileInfo, category: str) -> None: diff --git a/explain_codebase/renderers/cli_renderer.py b/explain_codebase/renderers/cli_renderer.py index bb85734..e463a70 100644 --- a/explain_codebase/renderers/cli_renderer.py +++ b/explain_codebase/renderers/cli_renderer.py @@ -1,19 +1,19 @@ from __future__ import annotations from collections import OrderedDict -from pathlib import Path from rich.console import Console from explain_codebase.models.analysis_result import AnalysisResult, FileExplanation +from explain_codebase.utils.output_utils import terminal_safe_text class CliRenderer: - HEADER = "Explain Codebase\n" + ("─" * 32) + HEADER = "Explain Codebase\n" + ("-" * 32) DEFAULT_LIST_LIMIT = 10 def render(self, result: AnalysisResult, verbose: bool = False, deep: bool = False) -> None: - console = Console() + console = Console(markup=False) console.print(self.HEADER) console.print() @@ -42,9 +42,10 @@ def render(self, result: AnalysisResult, verbose: bool = False, deep: bool = Fal def render_repository_section(self, console: Console, result: AnalysisResult) -> None: console.print("Repository") console.print() - console.print(f" Path {result.project_root}") - console.print(f" Type {result.project_type}") - console.print(f" Language {', '.join(result.languages) or 'unknown'}") + console.print(f" Path {terminal_safe_text(result.project_root)}") + console.print(f" Type {terminal_safe_text(result.project_type)}") + languages = ", ".join(terminal_safe_text(language) for language in result.languages) + console.print(f" Language {languages or 'unknown'}") console.print(f" Files {result.total_files}") console.print() @@ -59,14 +60,14 @@ def render_architecture_summary(self, console: Console, result: AnalysisResult, def render_suggested_starting_point(self, console: Console, result: AnalysisResult) -> None: console.print("Suggested starting point") console.print() - console.print(f" {self._suggested_starting_point(result)}") + console.print(f" {terminal_safe_text(self._suggested_starting_point(result))}") def render_entrypoints(self, console: Console, result: AnalysisResult) -> None: console.print() console.print("Entrypoints") console.print() for item in self._limit_list(result.entrypoints): - console.print(f" {item}") + console.print(f" {terminal_safe_text(item)}") if not result.entrypoints: console.print(" None detected") @@ -77,7 +78,7 @@ def render_core_modules(self, console: Console, result: AnalysisResult) -> None: console.print() if result.core_module_rankings: for item in result.core_module_rankings[: self.DEFAULT_LIST_LIMIT]: - console.print(f" {item.path}") + console.print(f" {terminal_safe_text(item.path)}") else: console.print(" None detected") @@ -87,7 +88,7 @@ def render_side_effect_modules(self, console: Console, result: AnalysisResult) - console.print(title) console.print() for item in self._limit_list(result.side_effect_modules): - console.print(f" {item}") + console.print(f" {terminal_safe_text(item)}") if not result.side_effect_modules: console.print(" None detected") @@ -107,7 +108,10 @@ def render_file_roles(self, console: Console, result: AnalysisResult) -> None: title = self._title_with_limit("File roles", len(result.file_roles)) console.print(title) console.print() - items = list(sorted(result.file_roles.items()))[: self.DEFAULT_LIST_LIMIT] + items = [ + (terminal_safe_text(path), terminal_safe_text(role)) + for path, role in sorted(result.file_roles.items())[: self.DEFAULT_LIST_LIMIT] + ] if not items: console.print(" None detected") return @@ -122,8 +126,8 @@ def render_deep_analysis(self, console: Console, result: AnalysisResult) -> None console.print() if result.architecture_issues: for issue in result.architecture_issues[: self.DEFAULT_LIST_LIMIT]: - console.print(self._format_issue_title(issue.issue_type)) - console.print(f" {self._format_issue_body(issue.issue_type, issue.description)}") + console.print(terminal_safe_text(self._format_issue_title(issue.issue_type))) + console.print(f" {terminal_safe_text(self._format_issue_body(issue.issue_type, issue.description))}") console.print() else: console.print(" No architecture issues detected") @@ -133,7 +137,7 @@ def render_deep_analysis(self, console: Console, result: AnalysisResult) -> None console.print() if result.large_files: for item in result.large_files[: self.DEFAULT_LIST_LIMIT]: - console.print(f" {item.path} ({item.loc} LOC)") + console.print(f" {terminal_safe_text(item.path)} ({item.loc} LOC)") else: console.print(" None detected") @@ -142,34 +146,34 @@ def render_deep_analysis(self, console: Console, result: AnalysisResult) -> None console.print() if result.hotspots: for item in result.hotspots[: self.DEFAULT_LIST_LIMIT]: - console.print(f" {item.path}") + console.print(f" {terminal_safe_text(item.path)}") else: console.print(" None detected") def render_onboarding(self, project_root: str, onboarding_path: list[str]) -> None: - console = Console() + console = Console(markup=False) console.print(self.HEADER) console.print() console.print("Repository") console.print() - console.print(f" Path {project_root}") + console.print(f" Path {terminal_safe_text(project_root)}") console.print() console.print("Suggested starting points") console.print() if onboarding_path: for index, path in enumerate(onboarding_path, start=1): - console.print(f" {index}. {path}") + console.print(f" {index}. {terminal_safe_text(path)}") else: console.print(" No recommended reading path inferred") def render_file_explanation(self, explanation: FileExplanation) -> None: - console = Console() + console = Console(markup=False) console.print(self.HEADER) console.print() console.print("File") console.print() - console.print(f" Path {explanation.path}") - console.print(f" Role {explanation.role}") + console.print(f" Path {terminal_safe_text(explanation.path)}") + console.print(f" Role {terminal_safe_text(explanation.role)}") console.print(f" Lines {explanation.line_count}") console.print(f" Used by {explanation.incoming_imports}") console.print(f" Depends on {explanation.outgoing_imports}") @@ -183,7 +187,7 @@ def _render_simple_list(self, console: Console, title: str, values: list[str]) - console.print() if values: for value in values[: self.DEFAULT_LIST_LIMIT]: - console.print(f" {value}") + console.print(f" {terminal_safe_text(value)}") else: console.print(" None detected") console.print() @@ -217,7 +221,7 @@ def _render_execution_flow_lines(self, flows: list[list[str]]) -> list[str]: lines: list[str] = [] root_items = list(tree.items())[: self.DEFAULT_LIST_LIMIT] for index, (root, children) in enumerate(root_items): - lines.append(root) + lines.append(terminal_safe_text(root)) lines.extend(self._render_tree_children(children, prefix="")) if index < len(root_items) - 1: lines.append("") @@ -228,9 +232,9 @@ def _render_tree_children(self, children: OrderedDict[str, OrderedDict], prefix: items = list(children.items())[: self.DEFAULT_LIST_LIMIT] for index, (name, subtree) in enumerate(items): is_last = index == len(items) - 1 - branch = "└─ " if is_last else "├─ " - lines.append(f"{prefix}{branch}{name}") - child_prefix = f"{prefix}{' ' if is_last else '│ '}" + branch = "`- " if is_last else "|- " + lines.append(f"{prefix}{branch}{terminal_safe_text(name)}") + child_prefix = f"{prefix}{' ' if is_last else '| '}" lines.extend(self._render_tree_children(subtree, child_prefix)) return lines diff --git a/explain_codebase/renderers/graph_renderer.py b/explain_codebase/renderers/graph_renderer.py index f0f255d..21eab84 100644 --- a/explain_codebase/renderers/graph_renderer.py +++ b/explain_codebase/renderers/graph_renderer.py @@ -6,10 +6,15 @@ from html import escape from math import log1p from pathlib import Path +from secrets import token_urlsafe import networkx as nx from explain_codebase.models.analysis_result import AnalysisResult +from explain_codebase.utils.output_utils import atomic_write_text + +VIS_NETWORK_URL = "https://unpkg.com/vis-network@9.1.9/dist/vis-network.min.js" +VIS_NETWORK_SRI = "sha384-6ox9IspbVlrc5vabD45kZcCJ8HeSwMAQjf9Iq48U/+srTVTNzsB7EqDC5oYpA0WC" @dataclass(frozen=True) @@ -51,7 +56,7 @@ def render( options: GraphViewOptions | None = None, ) -> Path: html = self._build_graph_document(result, graph, title="Dependency Graph", options=options or GraphViewOptions()) - output_path.write_text(html, encoding="utf-8") + atomic_write_text(output_path, html) return output_path def build_graph_fragment( @@ -60,10 +65,11 @@ def build_graph_fragment( graph: nx.DiGraph, container_id: str, options: GraphViewOptions | None = None, + script_nonce: str | None = None, ) -> str: options = options or GraphViewOptions() payload_json = json.dumps(self._build_payload(result, graph, options)).replace("", "<\\/") - return self._build_fragment_markup(container_id, payload_json) + return self._build_fragment_markup(container_id, payload_json, script_nonce=script_nonce) def _build_graph_document( self, @@ -72,11 +78,20 @@ def _build_graph_document( title: str, options: GraphViewOptions, ) -> str: - fragment = self.build_graph_fragment(result, graph, container_id="dependency-graph", options=options) + script_nonce = token_urlsafe(24) + fragment = self.build_graph_fragment( + result, + graph, + container_id="dependency-graph", + options=options, + script_nonce=script_nonce, + ) + content_security_policy = self.content_security_policy(script_nonce) return f"""
+