diff --git a/.github/workflows/publish-whygraph-image.yml b/.github/workflows/cd-deploy-whygraph.yml similarity index 53% rename from .github/workflows/publish-whygraph-image.yml rename to .github/workflows/cd-deploy-whygraph.yml index 3ec9c8a..d36503d 100644 --- a/.github/workflows/publish-whygraph-image.yml +++ b/.github/workflows/cd-deploy-whygraph.yml @@ -1,29 +1,14 @@ -name: Publish WhyGraph Docker image +name: cd-deploy-whygraph # Builds and pushes ghcr.io/mtrdesign/whygraph — the self-contained # scanning-service image the `whygraph` host shim (scripts/install.sh) -# runs. Triggers: -# - any push to main that touches the image, the package source, or this workflow -# - manual workflow_dispatch (with optional pinned codegraph version + release tag) +# runs. Publishing is tied to releases: cutting a GitHub release (tagged +# vX.Y.Z) builds the image and tags it to match that version, so the image +# tag follows the release version 1:1. on: - push: - branches: [main] - paths: - - 'docker/whygraph/**' - - 'src/whygraph/**' - - 'pyproject.toml' - - '.github/workflows/publish-whygraph-image.yml' - workflow_dispatch: - inputs: - codegraph_version: - description: 'npm version of @colbymchenry/codegraph to bake in (e.g. 1.2.3 or "latest")' - required: false - default: 'latest' - release_tag: - description: 'Optional human-readable release tag (e.g. v1.2.3) — published alongside :latest and :sha-XXXX' - required: false - default: '' + release: + types: [published] permissions: contents: read @@ -31,6 +16,7 @@ permissions: jobs: build-and-push: + name: Build and push image runs-on: ubuntu-latest steps: - name: Checkout @@ -49,15 +35,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # The release tag drives the image tags. A release `v1.2.3` publishes + # :1.2.3, :1.2, :1 and :latest, so consumers can pin as loosely or as + # tightly as they like while the shim's default :latest tracks the + # newest release. - name: Compute image tags id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/mtrdesign/whygraph tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix=sha- - type=raw,value=${{ github.event.inputs.release_tag }},enable=${{ github.event.inputs.release_tag != '' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest - name: Build and push uses: docker/build-push-action@v6 @@ -69,6 +60,6 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | - CODEGRAPH_VERSION=${{ github.event.inputs.codegraph_version || 'latest' }} + CODEGRAPH_VERSION=latest cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/ci-code-checks.yml b/.github/workflows/ci-code-checks.yml new file mode 100644 index 0000000..13f8c3b --- /dev/null +++ b/.github/workflows/ci-code-checks.yml @@ -0,0 +1,50 @@ +name: ci-code-checks + +# Runs on every pull request: lints with ruff (lint + format check) and runs +# the pytest suite. Both jobs run in parallel and gate the PR. + +on: + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --dev + + - name: Ruff lint + run: uv run ruff check src/ tests/ + + - name: Ruff format check + run: uv run ruff format --check src/ tests/ + + test: + name: Unit tests (pytest) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --dev + + - name: Run pytest + run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index a5645e5..69ff2d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,5 +30,9 @@ packages = ["src/whygraph"] [tool.pytest.ini_options] testpaths = ["tests"] +[tool.ruff] +target-version = "py312" +line-length = 88 + [dependency-groups] -dev = ["pytest>=8"] +dev = ["pytest>=8", "ruff>=0.8"] diff --git a/src/whygraph/analyze/llm_descriptor.py b/src/whygraph/analyze/llm_descriptor.py index 4ab8003..533bf81 100644 --- a/src/whygraph/analyze/llm_descriptor.py +++ b/src/whygraph/analyze/llm_descriptor.py @@ -125,16 +125,12 @@ def __init__( self._describe_prompt = ( describe_prompt if describe_prompt is not None - else resolve( - _PROMPT_COMPONENT, "describe", client.provider, client.model - ) + else resolve(_PROMPT_COMPONENT, "describe", client.provider, client.model) ) self._synthesis_prompt = ( synthesis_prompt if synthesis_prompt is not None - else resolve( - _PROMPT_COMPONENT, "synthesis", client.provider, client.model - ) + else resolve(_PROMPT_COMPONENT, "synthesis", client.provider, client.model) ) def __repr__(self) -> str: diff --git a/src/whygraph/analyze/rationale_generator.py b/src/whygraph/analyze/rationale_generator.py index ceb8484..7cde31b 100644 --- a/src/whygraph/analyze/rationale_generator.py +++ b/src/whygraph/analyze/rationale_generator.py @@ -139,8 +139,7 @@ def _format_evidence(evidence: Sequence[CommitEvidence]) -> str: n_prs = sum(len(item.pull_requests) for item in evidence) n_issues = sum(len(item.issues) for item in evidence) blocks = [ - f"Evidence: {len(evidence)} commit(s), {n_prs} PR(s), " - f"{n_issues} issue(s)." + f"Evidence: {len(evidence)} commit(s), {n_prs} PR(s), {n_issues} issue(s)." ] for item in evidence: lines = _format_commit(item.commit) @@ -224,8 +223,7 @@ def _format_symbol_context(context: SymbolContext) -> str: lines.append("") lines.extend( _format_relations( - f"Called by ({len(context.callers)} caller(s) — " - "blast radius of a change):", + f"Called by ({len(context.callers)} caller(s) — blast radius of a change):", context.callers, ) ) @@ -305,9 +303,7 @@ def _parse_rationale_json(text: str) -> dict: if not isinstance(value, list) or not all( isinstance(item, str) for item in value ): - raise RationaleError( - f"rationale key {key!r} must be a list of strings" - ) + raise RationaleError(f"rationale key {key!r} must be a list of strings") return parsed @@ -355,9 +351,7 @@ def __init__( self._rationale_prompt = ( rationale_prompt if rationale_prompt is not None - else resolve( - _PROMPT_COMPONENT, "rationale", client.provider, client.model - ) + else resolve(_PROMPT_COMPONENT, "rationale", client.provider, client.model) ) def __repr__(self) -> str: diff --git a/src/whygraph/assets.py b/src/whygraph/assets.py index e418005..421adc4 100644 --- a/src/whygraph/assets.py +++ b/src/whygraph/assets.py @@ -110,8 +110,7 @@ def packaged_assets_for(target: AgentTarget) -> Traversable: """ if target.assets_subdir is None: raise ValueError( - f"agent {target.name!r} has no bundled assets " - "(assets_subdir is None)" + f"agent {target.name!r} has no bundled assets (assets_subdir is None)" ) return resources.files("whygraph") / "assets" / target.assets_subdir @@ -167,17 +166,19 @@ def install_assets( If ``target`` has no bundled assets configured. """ if not target.has_assets: - raise ValueError( - f"agent {target.name!r} has no bundled assets to install" - ) + raise ValueError(f"agent {target.name!r} has no bundled assets to install") src: Traversable | Path = ( source if source is not None else packaged_assets_for(target) ) - assert target.assets_dest is not None # for type checkers; has_assets guarantees this + assert ( + target.assets_dest is not None + ) # for type checkers; has_assets guarantees this dest_root = project_root.joinpath(*target.assets_dest) merge_set = frozenset(target.assets_merge_files) result = InstallResult() - _copy_tree(src, dest_root, rel_prefix=(), merge_set=merge_set, force=force, result=result) + _copy_tree( + src, dest_root, rel_prefix=(), merge_set=merge_set, force=force, result=result + ) return result @@ -282,7 +283,11 @@ def _merge_block( # No markers — append the block after the existing content, # ensuring exactly one blank line of separation. - separator = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n") + separator = ( + "" + if existing.endswith("\n\n") + else ("\n" if existing.endswith("\n") else "\n\n") + ) dest_path.write_text(existing + separator + block, encoding="utf-8") result.overwritten.append(dest_path) diff --git a/src/whygraph/cli/commands/hooks.py b/src/whygraph/cli/commands/hooks.py index 25f7f10..81a3b73 100644 --- a/src/whygraph/cli/commands/hooks.py +++ b/src/whygraph/cli/commands/hooks.py @@ -145,7 +145,9 @@ def uninstall_cmd() -> None: removed_any = True console.print( - "Auto-rescan hooks uninstalled." if removed_any else "No WhyGraph hooks were installed." + "Auto-rescan hooks uninstalled." + if removed_any + else "No WhyGraph hooks were installed." ) @@ -163,7 +165,9 @@ def status_cmd() -> None: console.print(f"Hooks dir: {hooks_dir}") helper = project_root / HELPER_RELPATH - console.print(f"Helper: {'present' if helper.exists() else 'missing'} ({helper})") + console.print( + f"Helper: {'present' if helper.exists() else 'missing'} ({helper})" + ) for name in HOOK_NAMES: hp = hooks_dir / name if hp.exists() and SENTINEL in hp.read_text(): diff --git a/src/whygraph/cli/commands/scan.py b/src/whygraph/cli/commands/scan.py index 7840dd7..556d199 100644 --- a/src/whygraph/cli/commands/scan.py +++ b/src/whygraph/cli/commands/scan.py @@ -221,9 +221,7 @@ def _refresh_codegraph(project_root: Path, *, image: str | None) -> None: try: refresh_codegraph_index(project_root, image=image) except CodeGraphBootstrapError as exc: - console.print( - Text(f"CodeGraph refresh skipped — {exc}", style="yellow") - ) + console.print(Text(f"CodeGraph refresh skipped — {exc}", style="yellow")) def _select_github_client( @@ -306,7 +304,10 @@ def _render_scan_panel( ] if github_client is None: rows.append( - ("GitHub", Text(_github_skip_reason(config, remote_enabled), style="yellow")) + ( + "GitHub", + Text(_github_skip_reason(config, remote_enabled), style="yellow"), + ) ) else: rows.append( diff --git a/src/whygraph/cli/preflight.py b/src/whygraph/cli/preflight.py index b3922df..2ca88c9 100644 --- a/src/whygraph/cli/preflight.py +++ b/src/whygraph/cli/preflight.py @@ -87,9 +87,7 @@ def run_preflight(project_root: Path) -> None: if c.status == "missing" and c.hint: console.print(f" install: {c.hint}") - hard_missing = [ - c.name for c in checks if c.status == "missing" and not c.soft - ] + hard_missing = [c.name for c in checks if c.status == "missing" and not c.soft] if hard_missing: names = ", ".join(hard_missing) raise PreflightError( diff --git a/src/whygraph/core/config.py b/src/whygraph/core/config.py index 82446c8..a5a358c 100644 --- a/src/whygraph/core/config.py +++ b/src/whygraph/core/config.py @@ -197,13 +197,9 @@ def __post_init__(self) -> None: try: LogLevel[self.level.upper()] except KeyError as exc: - raise ConfigError( - f"invalid logging.level: {self.level!r}" - ) from exc + raise ConfigError(f"invalid logging.level: {self.level!r}") from exc if self.max_bytes < 1: - raise ConfigError( - f"logging.max_bytes must be >= 1, got {self.max_bytes}" - ) + raise ConfigError(f"logging.max_bytes must be >= 1, got {self.max_bytes}") if self.backup_count < 0: raise ConfigError( f"logging.backup_count must be >= 0, got {self.backup_count}" diff --git a/src/whygraph/db/migrations/versions/4bde3eda78f2_initial_schema.py b/src/whygraph/db/migrations/versions/4bde3eda78f2_initial_schema.py index 471c0ff..276be9a 100644 --- a/src/whygraph/db/migrations/versions/4bde3eda78f2_initial_schema.py +++ b/src/whygraph/db/migrations/versions/4bde3eda78f2_initial_schema.py @@ -1,19 +1,19 @@ """initial schema Revision ID: 4bde3eda78f2 -Revises: +Revises: Create Date: 2026-05-25 17:38:39.113102 """ + from typing import Sequence, Union from alembic import op import sqlalchemy as sa -import sqlmodel # revision identifiers, used by Alembic. -revision: str = '4bde3eda78f2' +revision: str = "4bde3eda78f2" down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,116 +22,146 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('author', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=True), - sa.Column('primary_login', sa.Text(), nullable=True), - sa.Column('primary_name', sa.Text(), nullable=True), - sa.Column('primary_email', sa.Text(), nullable=True), - sa.Column('emails', sa.Text(), nullable=False), - sa.Column('logins', sa.Text(), nullable=False), - sa.Column('names', sa.Text(), nullable=False), - sa.Column('first_seen', sa.Text(), nullable=True), - sa.Column('last_seen', sa.Text(), nullable=True), - sa.Column('commit_count', sa.Integer(), server_default=sa.text('0'), nullable=False), - sa.Column('pr_count', sa.Integer(), server_default=sa.text('0'), nullable=False), - sa.Column('issue_count', sa.Integer(), server_default=sa.text('0'), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "author", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=True), + sa.Column("primary_login", sa.Text(), nullable=True), + sa.Column("primary_name", sa.Text(), nullable=True), + sa.Column("primary_email", sa.Text(), nullable=True), + sa.Column("emails", sa.Text(), nullable=False), + sa.Column("logins", sa.Text(), nullable=False), + sa.Column("names", sa.Text(), nullable=False), + sa.Column("first_seen", sa.Text(), nullable=True), + sa.Column("last_seen", sa.Text(), nullable=True), + sa.Column( + "commit_count", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "pr_count", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "issue_count", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), ) - with op.batch_alter_table('author', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_author_primary_email'), ['primary_email'], unique=False) - batch_op.create_index(batch_op.f('ix_author_primary_login'), ['primary_login'], unique=False) - - op.create_table('commit', - sa.Column('sha', sa.Text(), nullable=True), - sa.Column('parent_shas', sa.Text(), nullable=False), - sa.Column('author_name', sa.Text(), nullable=False), - sa.Column('author_email', sa.Text(), nullable=False), - sa.Column('authored_at', sa.Text(), nullable=False), - sa.Column('committed_at', sa.Text(), nullable=False), - sa.Column('subject', sa.Text(), nullable=False), - sa.Column('body', sa.Text(), nullable=False), - sa.Column('files_changed', sa.Integer(), nullable=False), - sa.Column('insertions', sa.Integer(), nullable=False), - sa.Column('deletions', sa.Integer(), nullable=False), - sa.Column('scanned_at', sa.Text(), nullable=False), - sa.Column('llm_description', sa.Text(), nullable=True), - sa.Column('llm_description_model', sa.Text(), nullable=True), - sa.PrimaryKeyConstraint('sha') + with op.batch_alter_table("author", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_author_primary_email"), ["primary_email"], unique=False + ) + batch_op.create_index( + batch_op.f("ix_author_primary_login"), ["primary_login"], unique=False + ) + + op.create_table( + "commit", + sa.Column("sha", sa.Text(), nullable=True), + sa.Column("parent_shas", sa.Text(), nullable=False), + sa.Column("author_name", sa.Text(), nullable=False), + sa.Column("author_email", sa.Text(), nullable=False), + sa.Column("authored_at", sa.Text(), nullable=False), + sa.Column("committed_at", sa.Text(), nullable=False), + sa.Column("subject", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("files_changed", sa.Integer(), nullable=False), + sa.Column("insertions", sa.Integer(), nullable=False), + sa.Column("deletions", sa.Integer(), nullable=False), + sa.Column("scanned_at", sa.Text(), nullable=False), + sa.Column("llm_description", sa.Text(), nullable=True), + sa.Column("llm_description_model", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("sha"), ) - with op.batch_alter_table('commit', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_commit_authored_at'), ['authored_at'], unique=False) - - op.create_table('issue', - sa.Column('number', sa.Integer(), nullable=True), - sa.Column('title', sa.Text(), nullable=False), - sa.Column('body', sa.Text(), nullable=True), - sa.Column('state', sa.Text(), nullable=False), - sa.Column('created_at', sa.Text(), nullable=False), - sa.Column('updated_at', sa.Text(), nullable=False), - sa.Column('closed_at', sa.Text(), nullable=True), - sa.Column('author', sa.Text(), nullable=True), - sa.Column('html_url', sa.Text(), nullable=False), - sa.Column('labels', sa.Text(), nullable=False), - sa.Column('fetched_at', sa.Text(), nullable=False), - sa.PrimaryKeyConstraint('number') + with op.batch_alter_table("commit", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_commit_authored_at"), ["authored_at"], unique=False + ) + + op.create_table( + "issue", + sa.Column("number", sa.Integer(), nullable=True), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("state", sa.Text(), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("updated_at", sa.Text(), nullable=False), + sa.Column("closed_at", sa.Text(), nullable=True), + sa.Column("author", sa.Text(), nullable=True), + sa.Column("html_url", sa.Text(), nullable=False), + sa.Column("labels", sa.Text(), nullable=False), + sa.Column("fetched_at", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("number"), ) - with op.batch_alter_table('issue', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_issue_state'), ['state'], unique=False) - - op.create_table('pr_issue_link', - sa.Column('pr_number', sa.Integer(), nullable=False), - sa.Column('issue_number', sa.Integer(), nullable=False), - sa.Column('link_kind', sa.Text(), nullable=False), - sa.PrimaryKeyConstraint('pr_number', 'issue_number', 'link_kind') + with op.batch_alter_table("issue", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_issue_state"), ["state"], unique=False) + + op.create_table( + "pr_issue_link", + sa.Column("pr_number", sa.Integer(), nullable=False), + sa.Column("issue_number", sa.Integer(), nullable=False), + sa.Column("link_kind", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("pr_number", "issue_number", "link_kind"), ) - with op.batch_alter_table('pr_issue_link', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_pr_issue_link_issue_number'), ['issue_number'], unique=False) - - op.create_table('pull_request', - sa.Column('number', sa.Integer(), nullable=True), - sa.Column('title', sa.Text(), nullable=False), - sa.Column('body', sa.Text(), nullable=True), - sa.Column('state', sa.Text(), nullable=False), - sa.Column('draft', sa.Integer(), server_default=sa.text('0'), nullable=False), - sa.Column('created_at', sa.Text(), nullable=False), - sa.Column('updated_at', sa.Text(), nullable=False), - sa.Column('closed_at', sa.Text(), nullable=True), - sa.Column('merged_at', sa.Text(), nullable=True), - sa.Column('merge_commit_sha', sa.Text(), nullable=True), - sa.Column('head_sha', sa.Text(), nullable=False), - sa.Column('head_ref', sa.Text(), nullable=True), - sa.Column('base_ref', sa.Text(), nullable=False), - sa.Column('author', sa.Text(), nullable=True), - sa.Column('html_url', sa.Text(), nullable=False), - sa.Column('labels', sa.Text(), nullable=False), - sa.Column('fetched_at', sa.Text(), nullable=False), - sa.Column('commit_titles', sa.Text(), server_default=sa.text("'[]'"), nullable=False), - sa.Column('comments', sa.Text(), server_default=sa.text("'[]'"), nullable=False), - sa.PrimaryKeyConstraint('number') + with op.batch_alter_table("pr_issue_link", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_pr_issue_link_issue_number"), ["issue_number"], unique=False + ) + + op.create_table( + "pull_request", + sa.Column("number", sa.Integer(), nullable=True), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=True), + sa.Column("state", sa.Text(), nullable=False), + sa.Column("draft", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("updated_at", sa.Text(), nullable=False), + sa.Column("closed_at", sa.Text(), nullable=True), + sa.Column("merged_at", sa.Text(), nullable=True), + sa.Column("merge_commit_sha", sa.Text(), nullable=True), + sa.Column("head_sha", sa.Text(), nullable=False), + sa.Column("head_ref", sa.Text(), nullable=True), + sa.Column("base_ref", sa.Text(), nullable=False), + sa.Column("author", sa.Text(), nullable=True), + sa.Column("html_url", sa.Text(), nullable=False), + sa.Column("labels", sa.Text(), nullable=False), + sa.Column("fetched_at", sa.Text(), nullable=False), + sa.Column( + "commit_titles", sa.Text(), server_default=sa.text("'[]'"), nullable=False + ), + sa.Column( + "comments", sa.Text(), server_default=sa.text("'[]'"), nullable=False + ), + sa.PrimaryKeyConstraint("number"), ) - with op.batch_alter_table('pull_request', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_pull_request_merge_commit_sha'), ['merge_commit_sha'], unique=False) - batch_op.create_index(batch_op.f('ix_pull_request_state'), ['state'], unique=False) - - op.create_table('rationale_cache', - sa.Column('path', sa.Text(), nullable=False), - sa.Column('line_start', sa.Integer(), nullable=False), - sa.Column('line_end', sa.Integer(), nullable=False), - sa.Column('provider', sa.Text(), nullable=False), - sa.Column('model', sa.Text(), nullable=False), - sa.Column('evidence_fingerprint', sa.Text(), nullable=False), - sa.Column('cached_at', sa.Text(), nullable=False), - sa.Column('purpose', sa.Text(), nullable=False), - sa.Column('why', sa.Text(), nullable=False), - sa.Column('constraints', sa.Text(), nullable=False), - sa.Column('tradeoffs', sa.Text(), nullable=False), - sa.Column('risks', sa.Text(), nullable=False), - sa.Column('input_tokens', sa.Integer(), nullable=True), - sa.Column('output_tokens', sa.Integer(), nullable=True), - sa.Column('actual_provider', sa.Text(), nullable=True), - sa.Column('actual_model', sa.Text(), nullable=True), - sa.Column('qualified_name', sa.Text(), nullable=True), - sa.PrimaryKeyConstraint('path', 'line_start', 'line_end', 'provider', 'model') + with op.batch_alter_table("pull_request", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_pull_request_merge_commit_sha"), + ["merge_commit_sha"], + unique=False, + ) + batch_op.create_index( + batch_op.f("ix_pull_request_state"), ["state"], unique=False + ) + + op.create_table( + "rationale_cache", + sa.Column("path", sa.Text(), nullable=False), + sa.Column("line_start", sa.Integer(), nullable=False), + sa.Column("line_end", sa.Integer(), nullable=False), + sa.Column("provider", sa.Text(), nullable=False), + sa.Column("model", sa.Text(), nullable=False), + sa.Column("evidence_fingerprint", sa.Text(), nullable=False), + sa.Column("cached_at", sa.Text(), nullable=False), + sa.Column("purpose", sa.Text(), nullable=False), + sa.Column("why", sa.Text(), nullable=False), + sa.Column("constraints", sa.Text(), nullable=False), + sa.Column("tradeoffs", sa.Text(), nullable=False), + sa.Column("risks", sa.Text(), nullable=False), + sa.Column("input_tokens", sa.Integer(), nullable=True), + sa.Column("output_tokens", sa.Integer(), nullable=True), + sa.Column("actual_provider", sa.Text(), nullable=True), + sa.Column("actual_model", sa.Text(), nullable=True), + sa.Column("qualified_name", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("path", "line_start", "line_end", "provider", "model"), ) # ### end Alembic commands ### @@ -139,27 +169,27 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('rationale_cache') - with op.batch_alter_table('pull_request', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_pull_request_state')) - batch_op.drop_index(batch_op.f('ix_pull_request_merge_commit_sha')) + op.drop_table("rationale_cache") + with op.batch_alter_table("pull_request", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_pull_request_state")) + batch_op.drop_index(batch_op.f("ix_pull_request_merge_commit_sha")) - op.drop_table('pull_request') - with op.batch_alter_table('pr_issue_link', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_pr_issue_link_issue_number')) + op.drop_table("pull_request") + with op.batch_alter_table("pr_issue_link", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_pr_issue_link_issue_number")) - op.drop_table('pr_issue_link') - with op.batch_alter_table('issue', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_issue_state')) + op.drop_table("pr_issue_link") + with op.batch_alter_table("issue", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_issue_state")) - op.drop_table('issue') - with op.batch_alter_table('commit', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_commit_authored_at')) + op.drop_table("issue") + with op.batch_alter_table("commit", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_commit_authored_at")) - op.drop_table('commit') - with op.batch_alter_table('author', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_author_primary_login')) - batch_op.drop_index(batch_op.f('ix_author_primary_email')) + op.drop_table("commit") + with op.batch_alter_table("author", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_author_primary_login")) + batch_op.drop_index(batch_op.f("ix_author_primary_email")) - op.drop_table('author') + op.drop_table("author") # ### end Alembic commands ### diff --git a/src/whygraph/db/migrations/versions/7f2a8c1d4e3b_add_commit_file_change.py b/src/whygraph/db/migrations/versions/7f2a8c1d4e3b_add_commit_file_change.py index 9fd7dfd..d63c111 100644 --- a/src/whygraph/db/migrations/versions/7f2a8c1d4e3b_add_commit_file_change.py +++ b/src/whygraph/db/migrations/versions/7f2a8c1d4e3b_add_commit_file_change.py @@ -9,6 +9,7 @@ (commit, path-at-that-commit); rename edges live in ``renamed_from`` and are walked recursively at query time. """ + from typing import Sequence, Union from alembic import op @@ -32,8 +33,12 @@ def upgrade() -> None: sa.Column("change_type", sa.Text(), nullable=False), sa.Column("renamed_from", sa.Text(), nullable=True), sa.Column("similarity", sa.Integer(), nullable=True), - sa.Column("lines_added", sa.Integer(), nullable=False, server_default=sa.text("0")), - sa.Column("lines_deleted", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column( + "lines_added", sa.Integer(), nullable=False, server_default=sa.text("0") + ), + sa.Column( + "lines_deleted", sa.Integer(), nullable=False, server_default=sa.text("0") + ), sa.ForeignKeyConstraint(["commit_sha"], ["commit.sha"]), sa.PrimaryKeyConstraint("id"), ) diff --git a/src/whygraph/db/migrations/versions/9c3d6e2af0b1_add_refactor_score.py b/src/whygraph/db/migrations/versions/9c3d6e2af0b1_add_refactor_score.py index ed25bae..3c8abf3 100644 --- a/src/whygraph/db/migrations/versions/9c3d6e2af0b1_add_refactor_score.py +++ b/src/whygraph/db/migrations/versions/9c3d6e2af0b1_add_refactor_score.py @@ -8,6 +8,7 @@ means every existing row is treated as "not boring" until the next scan recomputes scores from ``commit_file_change`` rows. """ + from typing import Sequence, Union from alembic import op diff --git a/src/whygraph/db/migrations/versions/a1f7c2e9b4d8_add_commit_file_change_descriptions.py b/src/whygraph/db/migrations/versions/a1f7c2e9b4d8_add_commit_file_change_descriptions.py index ba30881..052acbf 100644 --- a/src/whygraph/db/migrations/versions/a1f7c2e9b4d8_add_commit_file_change_descriptions.py +++ b/src/whygraph/db/migrations/versions/a1f7c2e9b4d8_add_commit_file_change_descriptions.py @@ -11,6 +11,7 @@ Both are nullable and default ``NULL``; normal commits never populate them and keep using the whole-diff ``commit.llm_description``. """ + from typing import Sequence, Union from alembic import op diff --git a/src/whygraph/mcp/area_history.py b/src/whygraph/mcp/area_history.py index 0ed5c98..16d8944 100644 --- a/src/whygraph/mcp/area_history.py +++ b/src/whygraph/mcp/area_history.py @@ -77,9 +77,7 @@ def whygraph_area_history( if limit < 1: raise WhyGraphError("limit must be >= 1") - items = area_history_commits( - path, limit=limit, include_renames=include_renames - ) + items = area_history_commits(path, limit=limit, include_renames=include_renames) backfill_evidence_descriptions(items, target_path=path) return { "path": path, diff --git a/src/whygraph/mcp/evidence.py b/src/whygraph/mcp/evidence.py index 9176feb..053d8bc 100644 --- a/src/whygraph/mcp/evidence.py +++ b/src/whygraph/mcp/evidence.py @@ -271,7 +271,9 @@ def _walk_past_boring( # an ignored SHA can't be resolved), bail out cleanly and # keep whatever we already have. break - new_walked = [h for h in hunks if not h.is_uncommitted and h.sha not in seen_shas] + new_walked = [ + h for h in hunks if not h.is_uncommitted and h.sha not in seen_shas + ] walked.extend(new_walked) seen_shas.update(h.sha for h in new_walked) new_boring = _boring_shas_in({h.sha for h in new_walked}) - ignored @@ -406,8 +408,7 @@ def backfill_evidence_descriptions( normal = [ it.commit for it in items - if it.commit.files_changed <= threshold - and it.commit.llm_description is None + if it.commit.files_changed <= threshold and it.commit.llm_description is None ] if not bulk and not normal: return diff --git a/src/whygraph/mcp/resources.py b/src/whygraph/mcp/resources.py index 54db237..29a7ee0 100644 --- a/src/whygraph/mcp/resources.py +++ b/src/whygraph/mcp/resources.py @@ -246,15 +246,9 @@ def _repo_overview_resource() -> dict: _log.debug("repo overview resource read") try: with get_session() as session: - commit_count = session.exec( - select(func.count()).select_from(Commit) - ).one() - pr_count = session.exec( - select(func.count()).select_from(PullRequest) - ).one() - issue_count = session.exec( - select(func.count()).select_from(Issue) - ).one() + commit_count = session.exec(select(func.count()).select_from(Commit)).one() + pr_count = session.exec(select(func.count()).select_from(PullRequest)).one() + issue_count = session.exec(select(func.count()).select_from(Issue)).one() link_count = session.exec( select(func.count()).select_from(PRIssueLink) ).one() @@ -265,9 +259,7 @@ def _repo_overview_resource() -> dict: func.max(Commit.authored_at), ) ).one() - latest_scanned_at = session.exec( - select(func.max(Commit.scanned_at)) - ).one() + latest_scanned_at = session.exec(select(func.max(Commit.scanned_at))).one() latest_pr_fetched_at = session.exec( select(func.max(PullRequest.fetched_at)) ).one() diff --git a/src/whygraph/mcp/targets.py b/src/whygraph/mcp/targets.py index c3715b9..d8db255 100644 --- a/src/whygraph/mcp/targets.py +++ b/src/whygraph/mcp/targets.py @@ -110,8 +110,7 @@ def resolve_target( if qualified_name: if path or line_start or line_end: raise WhyGraphError( - "pass either qualified_name OR (path, line_start, line_end), " - "not both" + "pass either qualified_name OR (path, line_start, line_end), not both" ) try: with CodeGraph.for_repository( diff --git a/src/whygraph/scan/git_crawler.py b/src/whygraph/scan/git_crawler.py index 419e2e0..7df6652 100644 --- a/src/whygraph/scan/git_crawler.py +++ b/src/whygraph/scan/git_crawler.py @@ -57,9 +57,7 @@ def work(self) -> None: self.set_total(len(commits)) with get_session() as session: - existing_commits: set[str] = set( - session.exec(select(CommitRow.sha)).all() - ) + existing_commits: set[str] = set(session.exec(select(CommitRow.sha)).all()) existing_file_changes: set[str] = set( session.exec(select(CommitFileChange.commit_sha).distinct()).all() ) @@ -92,9 +90,7 @@ def work(self) -> None: self.advance(1) -def _to_row( - dc: CommitDC, *, scanned_at: str, refactor_score: int = 0 -) -> CommitRow: +def _to_row(dc: CommitDC, *, scanned_at: str, refactor_score: int = 0) -> CommitRow: return CommitRow( sha=dc.sha, parent_shas=" ".join(dc.parent_shas), diff --git a/src/whygraph/scan/refactor_score.py b/src/whygraph/scan/refactor_score.py index d17e10c..1ae706f 100644 --- a/src/whygraph/scan/refactor_score.py +++ b/src/whygraph/scan/refactor_score.py @@ -69,9 +69,7 @@ def compute_refactor_score( score += 10 if n_files > 0: - renames = sum( - 1 for ch in file_changes if ch.change_type in ("R", "C") - ) + renames = sum(1 for ch in file_changes if ch.change_type in ("R", "C")) ratio = renames / n_files if ratio >= 0.8: score += 40 diff --git a/src/whygraph/services/codegraph/bootstrap.py b/src/whygraph/services/codegraph/bootstrap.py index 25f95ef..aa66b49 100644 --- a/src/whygraph/services/codegraph/bootstrap.py +++ b/src/whygraph/services/codegraph/bootstrap.py @@ -190,8 +190,7 @@ def _run_codegraph( subprocess.run(cmd, check=True, cwd=cwd) except subprocess.CalledProcessError as exc: raise CodeGraphBootstrapError( - f"`codegraph {label}` failed (exit {exc.returncode})" - " — see output above" + f"`codegraph {label}` failed (exit {exc.returncode}) — see output above" ) from exc diff --git a/src/whygraph/services/codegraph/graph.py b/src/whygraph/services/codegraph/graph.py index e8cbd95..44407f8 100644 --- a/src/whygraph/services/codegraph/graph.py +++ b/src/whygraph/services/codegraph/graph.py @@ -107,7 +107,9 @@ def for_repository( If the resolved database does not exist — most often because ``codegraph init`` has not been run. """ - return cls(codegraph_db if codegraph_db is not None else root / CODEGRAPH_DB_RELPATH) + return cls( + codegraph_db if codegraph_db is not None else root / CODEGRAPH_DB_RELPATH + ) def __repr__(self) -> str: return f"CodeGraph(db_path={self.db_path!r})" diff --git a/src/whygraph/services/git/repository.py b/src/whygraph/services/git/repository.py index 2f62072..c593a3f 100644 --- a/src/whygraph/services/git/repository.py +++ b/src/whygraph/services/git/repository.py @@ -181,9 +181,7 @@ def diff(self, commit: Commit, *, pathspec: str | None = None) -> str: else: argv = (f"{commit.parent_shas[0]}..{commit.sha}",) try: - return self._shell.run( - GitDiffCmd(*argv, pathspec=pathspec), cwd=self.root - ) + return self._shell.run(GitDiffCmd(*argv, pathspec=pathspec), cwd=self.root) except ShellError as exc: raise GitError( f"failed to diff {commit.sha[:7]} against its parent" @@ -257,9 +255,7 @@ def blame( cwd=self.root, ) except ShellError as exc: - raise GitError( - f"failed to blame {path}:{line_start}-{line_end}" - ) from exc + raise GitError(f"failed to blame {path}:{line_start}-{line_end}") from exc def commit_file_changes(self, commit: Commit) -> tuple[FileChange, ...]: """Per-file structural changes recorded by ``commit``. @@ -291,9 +287,7 @@ def commit_file_changes(self, commit: Commit) -> tuple[FileChange, ...]: If ``git diff-tree`` fails (unknown sha, broken repo). """ try: - return self._shell.run( - GitDiffTreeFileChangesCmd(commit.sha), cwd=self.root - ) + return self._shell.run(GitDiffTreeFileChangesCmd(commit.sha), cwd=self.root) except ShellError as exc: raise GitError( f"failed to inspect file changes for {commit.sha[:7]}" diff --git a/src/whygraph/services/github/pull_request.py b/src/whygraph/services/github/pull_request.py index da58d07..3b046d3 100644 --- a/src/whygraph/services/github/pull_request.py +++ b/src/whygraph/services/github/pull_request.py @@ -140,9 +140,7 @@ def from_graphql_node(cls, node: dict) -> "PullRequest": author = node.get("author") or {} label_nodes = ((node.get("labels") or {}).get("nodes")) or [] commit_nodes = ((node.get("commits") or {}).get("nodes")) or [] - closing_nodes = ( - (node.get("closingIssuesReferences") or {}).get("nodes") - ) or [] + closing_nodes = ((node.get("closingIssuesReferences") or {}).get("nodes")) or [] comment_nodes = ((node.get("comments") or {}).get("nodes")) or [] state_raw = str(node.get("state", "")).lower() diff --git a/src/whygraph/services/llm/client.py b/src/whygraph/services/llm/client.py index f3297ba..a8543b4 100644 --- a/src/whygraph/services/llm/client.py +++ b/src/whygraph/services/llm/client.py @@ -37,7 +37,9 @@ def __init__(self, *, model: str) -> None: self.model = model def __repr__(self) -> str: - return f"{type(self).__name__}(provider={self.provider!r}, model={self.model!r})" + return ( + f"{type(self).__name__}(provider={self.provider!r}, model={self.model!r})" + ) @classmethod @abc.abstractmethod diff --git a/src/whygraph/services/llm/factory.py b/src/whygraph/services/llm/factory.py index f94092d..fc6fcfc 100644 --- a/src/whygraph/services/llm/factory.py +++ b/src/whygraph/services/llm/factory.py @@ -115,8 +115,7 @@ def make( entry = self._registry.get(provider) if entry is None: raise LlmError( - f"unknown LLM provider: {provider!r}; " - f"available: {self.providers}" + f"unknown LLM provider: {provider!r}; available: {self.providers}" ) cls, config_obj = entry if model is not None: diff --git a/tests/conftest.py b/tests/conftest.py index 6af80fe..abd67f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,9 +189,7 @@ def _git(*args: str) -> None: @pytest.fixture -def whygraph_db( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> Iterator[Path]: +def whygraph_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: """Point WhyGraph's DB layer at an isolated, empty per-test SQLite file. Yields the path. The schema is *not* created — use diff --git a/tests/test_analyze_prompt.py b/tests/test_analyze_prompt.py index ca66ee5..22eb041 100644 --- a/tests/test_analyze_prompt.py +++ b/tests/test_analyze_prompt.py @@ -143,9 +143,7 @@ def test_resolve_mixes_rungs_per_file(tmp_path: Path) -> None: def test_resolve_synthesis_uses_prefixed_filenames(tmp_path: Path) -> None: _write(tmp_path, "comp", "default", "synthesis.system.md", "SYN-SYS") - _write( - tmp_path, "comp", "default", "synthesis.task.md", "SYN {{DESCRIPTIONS}}" - ) + _write(tmp_path, "comp", "default", "synthesis.task.md", "SYN {{DESCRIPTIONS}}") prompt = resolve("comp", "synthesis", "openai", "gpt-4o", prompts_dir=tmp_path) @@ -187,8 +185,7 @@ def test_resolve_isolates_components(tmp_path: Path) -> None: _write(tmp_path, "comp_a", "default", "task.md", "A-TASK") assert ( - resolve("comp_a", "describe", "p", "m", prompts_dir=tmp_path).system - == "A-SYS" + resolve("comp_a", "describe", "p", "m", prompts_dir=tmp_path).system == "A-SYS" ) with pytest.raises(AnalyzeError): resolve("comp_b", "describe", "p", "m", prompts_dir=tmp_path) diff --git a/tests/test_analyze_rationale_generator.py b/tests/test_analyze_rationale_generator.py index 90690d3..8a3fe55 100644 --- a/tests/test_analyze_rationale_generator.py +++ b/tests/test_analyze_rationale_generator.py @@ -15,7 +15,6 @@ import pytest from whygraph.analyze import ( - RATIONALE_PLACEHOLDER, AnalyzeError, CommitEvidence, Prompt, @@ -119,7 +118,10 @@ def _commit( def _pr( - *, number: int = 12, title: str = "Cache prompts", body: str | None = "Speeds up scans." + *, + number: int = 12, + title: str = "Cache prompts", + body: str | None = "Speeds up scans.", ) -> PullRequest: """A :class:`PullRequest` row with sensible defaults for tests.""" return PullRequest( @@ -340,9 +342,7 @@ def test_generate_raises_rationale_error_on_non_dict_json() -> None: def test_generate_raises_rationale_error_on_missing_key() -> None: - blob = json.dumps( - {"purpose": "p", "why": "w", "constraints": [], "tradeoffs": []} - ) + blob = json.dumps({"purpose": "p", "why": "w", "constraints": [], "tradeoffs": []}) client = _StubClient(text=blob) generator = RationaleGenerator(client) @@ -428,9 +428,7 @@ def test_generate_ignores_unknown_keys_in_output() -> None: def test_rationale_is_frozen() -> None: - rationale = RationaleGenerator(_StubClient()).generate( - [CommitEvidence(_commit())] - ) + rationale = RationaleGenerator(_StubClient()).generate([CommitEvidence(_commit())]) with pytest.raises(Exception): # FrozenInstanceError rationale.purpose = "changed" # type: ignore[misc] @@ -499,17 +497,13 @@ def test_format_symbol_context_renders_target_callers_and_callees() -> None: context = SymbolContext( target=_symbol(), callers=(_relation(qualified_name="whygraph.cli.analyze", line=42),), - callees=( - _relation(qualified_name="whygraph.analyze.prompt.render", line=120), - ), + callees=(_relation(qualified_name="whygraph.analyze.prompt.render", line=120),), ) text = _format_symbol_context(context) assert "CODE GRAPH CONTEXT" in text - assert ( - "Target: whygraph.analyze.RationaleGenerator.generate (method)" in text - ) + assert "Target: whygraph.analyze.RationaleGenerator.generate (method)" in text assert "def generate(self, evidence, *, symbol_context=None)" in text assert "Called by (1 caller(s)" in text assert "whygraph.cli.analyze (function)" in text @@ -525,9 +519,7 @@ def test_format_symbol_context_marks_empty_caller_and_callee_blocks() -> None: assert text.count("(none recorded)") == 2 -def test_format_symbol_context_falls_back_to_symbol_line_when_edge_lacks_one() -> ( - None -): +def test_format_symbol_context_falls_back_to_symbol_line_when_edge_lacks_one() -> None: context = SymbolContext( target=_symbol(), callers=(_relation(qualified_name="pkg.caller", line=None),), diff --git a/tests/test_assets.py b/tests/test_assets.py index 7272ed5..160b06c 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -223,9 +223,7 @@ def test_install_force_overwrites(tmp_path: Path) -> None: user_edit = project / ".claude" / "agents" / "x.md" user_edit.write_text("USER EDIT", encoding="utf-8") - result = assets.install_assets( - _claude_target(), project, source=src, force=True - ) + result = assets.install_assets(_claude_target(), project, source=src, force=True) assert user_edit.read_text(encoding="utf-8") == "X-AGENT" assert user_edit in result.overwritten @@ -262,9 +260,7 @@ def test_install_from_packaged_source(tmp_path: Path) -> None: result = assets.install_assets(_claude_target(), project) assert (project / ".claude" / "agents" / "planner.md").is_file() - assert ( - project / ".claude" / "skills" / "rationale" / "SKILL.md" - ).is_file() + assert (project / ".claude" / "skills" / "rationale" / "SKILL.md").is_file() assert (project / ".claude" / "skills" / "pre-edit" / "SKILL.md").is_file() # 4 agents + 4 skills = 8 files. assert len(result.written) == 8 @@ -317,7 +313,7 @@ def _merge_target() -> agents.AgentTarget: scope="project", format="json", description="ephemeral test target", - assets_subdir="ignored", # source is injected via the source= arg + assets_subdir="ignored", # source is injected via the source= arg assets_dest=("dest",), assets_merge_files=("merge-me.md",), ) diff --git a/tests/test_cli_analyze.py b/tests/test_cli_analyze.py index 8351d09..06181a4 100644 --- a/tests/test_cli_analyze.py +++ b/tests/test_cli_analyze.py @@ -221,9 +221,7 @@ def test_analyze_errors_when_baseline_not_in_db( assert stub_llm == [] -def test_analyze_rejects_unknown_ref( - isolated_db: Path, stub_llm: list[str] -) -> None: +def test_analyze_rejects_unknown_ref(isolated_db: Path, stub_llm: list[str]) -> None: result = CliRunner().invoke(whygraph_main, ["analyze", "nonexistentref"]) assert result.exit_code == 1 diff --git a/tests/test_cli_hooks.py b/tests/test_cli_hooks.py index de96014..cba1a17 100644 --- a/tests/test_cli_hooks.py +++ b/tests/test_cli_hooks.py @@ -120,6 +120,11 @@ def test_generated_shell_is_valid() -> None: _git_init() _install(runner) # `sh -n` parses without executing — catches quoting/syntax errors. - for path in [Path(HELPER_RELPATH), *(Path(".git/hooks") / n for n in HOOK_NAMES)]: - check = subprocess.run(["sh", "-n", str(path)], capture_output=True, text=True) + for path in [ + Path(HELPER_RELPATH), + *(Path(".git/hooks") / n for n in HOOK_NAMES), + ]: + check = subprocess.run( + ["sh", "-n", str(path)], capture_output=True, text=True + ) assert check.returncode == 0, f"{path}: {check.stderr}" diff --git a/tests/test_core_config_analyze.py b/tests/test_core_config_analyze.py index 8593a63..4e3801d 100644 --- a/tests/test_core_config_analyze.py +++ b/tests/test_core_config_analyze.py @@ -34,10 +34,7 @@ def test_analyze_defaults_when_section_omitted(tmp_path: Path) -> None: def test_analyze_section_parsed(tmp_path: Path) -> None: config = _write( tmp_path / "whygraph.toml", - '[analyze]\n' - 'provider = "openai"\n' - 'max_diff_chars = 1234\n' - 'timeout_sec = 90\n', + '[analyze]\nprovider = "openai"\nmax_diff_chars = 1234\ntimeout_sec = 90\n', ) cfg = Config.from_toml(config) @@ -64,7 +61,7 @@ def test_analyze_unknown_key_warns_but_loads( ) -> None: config = _write( tmp_path / "whygraph.toml", - "[analyze]\nprovider = \"anthropic\"\nbogus = true\n", + '[analyze]\nprovider = "anthropic"\nbogus = true\n', ) with caplog.at_level(logging.WARNING, logger="whygraph.core.config"): diff --git a/tests/test_core_config_logging.py b/tests/test_core_config_logging.py index 2a204fb..dc2558e 100644 --- a/tests/test_core_config_logging.py +++ b/tests/test_core_config_logging.py @@ -34,11 +34,11 @@ def test_logging_defaults_when_section_omitted(tmp_path: Path) -> None: def test_logging_section_parsed(tmp_path: Path) -> None: config = _write( tmp_path / "whygraph.toml", - '[logging]\n' + "[logging]\n" 'file = "logs/whygraph.log"\n' 'level = "DEBUG"\n' - 'max_bytes = 1024\n' - 'backup_count = 1\n', + "max_bytes = 1024\n" + "backup_count = 1\n", ) cfg = Config.from_toml(config) diff --git a/tests/test_core_config_rationale.py b/tests/test_core_config_rationale.py index 8898253..6571022 100644 --- a/tests/test_core_config_rationale.py +++ b/tests/test_core_config_rationale.py @@ -34,10 +34,7 @@ def test_rationale_defaults_when_section_omitted(tmp_path: Path) -> None: def test_rationale_section_parsed(tmp_path: Path) -> None: config = _write( tmp_path / "whygraph.toml", - '[rationale]\n' - 'provider = "openai"\n' - 'model = "gpt-4o"\n' - 'timeout_sec = 90\n', + '[rationale]\nprovider = "openai"\nmodel = "gpt-4o"\ntimeout_sec = 90\n', ) cfg = Config.from_toml(config) diff --git a/tests/test_core_logger_file.py b/tests/test_core_logger_file.py index 5128041..25738f3 100644 --- a/tests/test_core_logger_file.py +++ b/tests/test_core_logger_file.py @@ -63,9 +63,7 @@ def test_file_handler_attached_and_dir_created(tmp_path: Path) -> None: log_path = tmp_path / "logs" / "whygraph.log" assert not log_path.parent.exists() - root = configure_logging( - "INFO", file_config=LoggingConfig(file=log_path) - ) + root = configure_logging("INFO", file_config=LoggingConfig(file=log_path)) handlers = _file_handlers(root) assert len(handlers) == 1 diff --git a/tests/test_git_crawler.py b/tests/test_git_crawler.py index 197f176..638c751 100644 --- a/tests/test_git_crawler.py +++ b/tests/test_git_crawler.py @@ -59,9 +59,7 @@ def repo_root(tmp_path: Path) -> Path: @pytest.fixture(autouse=True) -def _isolate_db( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> Iterator[Path]: +def _isolate_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: """Point WhyGraph at a per-test SQLite file and pre-create the schema.""" db_path = tmp_path / "whygraph.db" monkeypatch.setattr(core, "_config", Config(whygraph_db=db_path)) @@ -103,8 +101,7 @@ def test_rescan_is_idempotent(repo_root: Path) -> None: GitCrawler(Progress(), repository=repo).run() with get_session() as session: first_rows = { - row.sha: row.scanned_at - for row in session.exec(select(CommitRow)).all() + row.sha: row.scanned_at for row in session.exec(select(CommitRow)).all() } # Ensure any newly-generated scanned_at would differ if rows were @@ -114,8 +111,7 @@ def test_rescan_is_idempotent(repo_root: Path) -> None: GitCrawler(Progress(), repository=repo).run() with get_session() as session: second_rows = { - row.sha: row.scanned_at - for row in session.exec(select(CommitRow)).all() + row.sha: row.scanned_at for row in session.exec(select(CommitRow)).all() } assert first_rows == second_rows @@ -167,9 +163,7 @@ def test_rescan_does_not_duplicate_file_changes(repo_root: Path) -> None: GitCrawler(Progress(), repository=repo).run() with get_session() as session: - count = session.exec( - select(func.count(CommitFileChange.id)) - ).one() + count = session.exec(select(func.count(CommitFileChange.id))).one() assert count == 3 @@ -202,9 +196,7 @@ def test_scan_computes_refactor_score_for_boring_commit(tmp_path: Path) -> None: repo.mkdir() def _g(*args: str) -> None: - subprocess.run( - ["git", "-C", str(repo), *args], check=True, capture_output=True - ) + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) _g("init", "-q", "-b", "main") _g("config", "user.email", "test@example.com") @@ -240,9 +232,7 @@ def test_scan_backfills_refactor_score_when_file_changes_arrive_late( repo.mkdir() def _g(*args: str) -> None: - subprocess.run( - ["git", "-C", str(repo), *args], check=True, capture_output=True - ) + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) _g("init", "-q", "-b", "main") _g("config", "user.email", "test@example.com") diff --git a/tests/test_init_agents.py b/tests/test_init_agents.py index 6cc244b..301c3e2 100644 --- a/tests/test_init_agents.py +++ b/tests/test_init_agents.py @@ -249,9 +249,7 @@ def _fake_db() -> Path: fake_db.touch() return fake_db - monkeypatch.setattr( - "whygraph.cli.commands.init._ensure_db_initialized", _fake_db - ) + monkeypatch.setattr("whygraph.cli.commands.init._ensure_db_initialized", _fake_db) monkeypatch.setattr( "whygraph.cli.commands.init._run_preflight", lambda project_root: None, @@ -284,9 +282,7 @@ def _fake() -> Path: assert "codex" in result.output -def test_init_no_flag_writes_no_agent_config( - stub_init, tmp_path: Path -) -> None: +def test_init_no_flag_writes_no_agent_config(stub_init, tmp_path: Path) -> None: result, cwd = _invoke_in(tmp_path, "init") assert result.exit_code == 0, result.output assert not (cwd / ".mcp.json").exists() @@ -295,9 +291,7 @@ def test_init_no_flag_writes_no_agent_config( assert "Initialized WhyGraph database" in result.output -def test_init_writes_example_config_not_real_config( - stub_init, tmp_path: Path -) -> None: +def test_init_writes_example_config_not_real_config(stub_init, tmp_path: Path) -> None: """A bare ``whygraph init`` drops a valid example, never whygraph.toml.""" result, cwd = _invoke_in(tmp_path, "init") assert result.exit_code == 0, result.output @@ -330,9 +324,7 @@ def test_init_gitignore_idempotent(stub_init, tmp_path: Path) -> None: runner = CliRunner() with runner.isolated_filesystem(temp_dir=tmp_path): cwd = Path.cwd() - (cwd / ".gitignore").write_text( - "node_modules/\n.whygraph/\n", encoding="utf-8" - ) + (cwd / ".gitignore").write_text("node_modules/\n.whygraph/\n", encoding="utf-8") result = runner.invoke(whygraph_main, ["init"]) assert result.exit_code == 0, result.output body = (cwd / ".gitignore").read_text(encoding="utf-8") @@ -373,26 +365,20 @@ def test_init_agent_claude_no_install_assets_skips_dot_claude( assert "Installed assets for" not in result.output -def test_init_agent_claude_force_overwrites_existing( - stub_init, tmp_path: Path -) -> None: +def test_init_agent_claude_force_overwrites_existing(stub_init, tmp_path: Path) -> None: # Pre-seed a user edit at the install destination. runner = CliRunner() with runner.isolated_filesystem(temp_dir=tmp_path): cwd = Path.cwd() (cwd / ".claude" / "agents").mkdir(parents=True) (cwd / ".claude" / "agents" / "planner.md").write_text("USER EDIT") - result = runner.invoke( - whygraph_main, ["init", "--agent", "claude", "--force"] - ) + result = runner.invoke(whygraph_main, ["init", "--agent", "claude", "--force"]) assert result.exit_code == 0, result.output text = (cwd / ".claude" / "agents" / "planner.md").read_text() assert text != "USER EDIT" -def test_init_agent_claude_default_skips_existing( - stub_init, tmp_path: Path -) -> None: +def test_init_agent_claude_default_skips_existing(stub_init, tmp_path: Path) -> None: """Without ``--force``, an existing .claude file is left alone.""" runner = CliRunner() with runner.isolated_filesystem(temp_dir=tmp_path): @@ -449,9 +435,7 @@ def test_init_agent_vscode_writes_mcp_and_installs_full_tree( assert data["mcpServers"]["whygraph"]["command"] == "whygraph-mcp" # Bundled assets land under .github/. assert (cwd / ".github" / "copilot-instructions.md").is_file() - assert ( - cwd / ".github" / "instructions" / "pre-edit.instructions.md" - ).is_file() + assert (cwd / ".github" / "instructions" / "pre-edit.instructions.md").is_file() assert (cwd / ".github" / "prompts" / "whygraph-plan.prompt.md").is_file() assert (cwd / ".github" / "agents" / "planner.agent.md").is_file() assert "Installed assets for vscode" in result.output @@ -494,9 +478,7 @@ def test_init_agent_vscode_merges_existing_copilot_instructions( assert "" in merged assert "" in merged # User content comes first. - assert merged.find("Our team rules") < merged.find( - "" - ) + assert merged.find("Our team rules") < merged.find("") def test_init_agent_codex_writes_and_installs_full_tree( @@ -516,9 +498,7 @@ def test_init_agent_codex_writes_and_installs_full_tree( assert config_path.exists() with config_path.open("rb") as f: config_data = tomllib.load(f) - assert ( - config_data["mcp_servers"]["whygraph"]["command"] == "whygraph-mcp" - ) + assert config_data["mcp_servers"]["whygraph"]["command"] == "whygraph-mcp" # AGENTS.md at the repo root has the WhyGraph block (append-merged). agents_md = cwd / "AGENTS.md" assert agents_md.is_file() @@ -533,9 +513,7 @@ def test_init_agent_codex_writes_and_installs_full_tree( assert not (cwd / ".cursor").exists() -def test_init_agent_codex_merges_existing_agents_md( - stub_init, tmp_path: Path -) -> None: +def test_init_agent_codex_merges_existing_agents_md(stub_init, tmp_path: Path) -> None: """User-authored AGENTS.md is preserved; the WhyGraph block appends.""" runner = CliRunner() with runner.isolated_filesystem(temp_dir=tmp_path): @@ -553,9 +531,7 @@ def test_init_agent_codex_merges_existing_agents_md( # WhyGraph block appended after user content. assert "" in merged assert "" in merged - assert merged.find("Our team rules") < merged.find( - "" - ) + assert merged.find("Our team rules") < merged.find("") def test_init_agent_claude_with_print_skips_mcp_write_but_installs_assets( diff --git a/tests/test_mcp_area_history.py b/tests/test_mcp_area_history.py index d69ec45..8596ef8 100644 --- a/tests/test_mcp_area_history.py +++ b/tests/test_mcp_area_history.py @@ -71,11 +71,35 @@ def _seed_rename_history(session) -> tuple[str, str, str, str]: c_rename2 — R mid.py → final.py c_recent — M final.py """ - session.add(_commit("sha_old", subject="add legacy", committed_at="2025-01-01T00:00:00+00:00")) - session.add(_commit("sha_mid", subject="tweak legacy", committed_at="2025-02-01T00:00:00+00:00")) - session.add(_commit("sha_rename1", subject="rename to mid", committed_at="2025-03-01T00:00:00+00:00")) - session.add(_commit("sha_rename2", subject="rename to final", committed_at="2025-04-01T00:00:00+00:00")) - session.add(_commit("sha_recent", subject="edit final", committed_at="2025-05-01T00:00:00+00:00")) + session.add( + _commit( + "sha_old", subject="add legacy", committed_at="2025-01-01T00:00:00+00:00" + ) + ) + session.add( + _commit( + "sha_mid", subject="tweak legacy", committed_at="2025-02-01T00:00:00+00:00" + ) + ) + session.add( + _commit( + "sha_rename1", + subject="rename to mid", + committed_at="2025-03-01T00:00:00+00:00", + ) + ) + session.add( + _commit( + "sha_rename2", + subject="rename to final", + committed_at="2025-04-01T00:00:00+00:00", + ) + ) + session.add( + _commit( + "sha_recent", subject="edit final", committed_at="2025-05-01T00:00:00+00:00" + ) + ) session.add(_change(commit_sha="sha_old", path="legacy.py", change_type="A")) session.add(_change(commit_sha="sha_mid", path="legacy.py", change_type="M")) @@ -117,7 +141,9 @@ def test_resolve_path_aliases_returns_only_seed_when_no_renames( whygraph_db_initialized: Path, ) -> None: with get_session() as session: - session.add(_commit("sha", subject="add", committed_at="2025-01-01T00:00:00+00:00")) + session.add( + _commit("sha", subject="add", committed_at="2025-01-01T00:00:00+00:00") + ) session.add(_change(commit_sha="sha", path="foo.py", change_type="A")) session.commit() aliases = resolve_path_aliases(session, "foo.py") diff --git a/tests/test_mcp_evidence.py b/tests/test_mcp_evidence.py index 8824e69..058cd32 100644 --- a/tests/test_mcp_evidence.py +++ b/tests/test_mcp_evidence.py @@ -96,12 +96,8 @@ def test_evidence_for_joins_commits_prs_and_issues( ) -> None: newest, oldest = list(Repository(temp_git_repo).commits) with get_session() as session: - session.add( - _db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00") - ) - session.add( - _db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00") - ) + session.add(_db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00")) + session.add(_db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00")) session.add(_db_pr(number=5, merge_commit_sha=newest.sha)) session.add(_db_issue(number=9)) session.add(PRIssueLink(pr_number=5, issue_number=9, link_kind="closes")) @@ -137,9 +133,7 @@ def test_evidence_for_skips_blamed_sha_absent_from_db( newest, _oldest = list(Repository(temp_git_repo).commits) with get_session() as session: # Only the newest commit is scanned; the older one is absent. - session.add( - _db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00") - ) + session.add(_db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00")) monkeypatch.chdir(temp_git_repo) result = whygraph_evidence_for(path="sample.py", line_start=1, line_end=3) @@ -207,9 +201,7 @@ def test_evidence_for_backfills_null_llm_description( newest, oldest = list(Repository(temp_git_repo).commits) with get_session() as session: # The oldest commit already has a description — must NOT be re-described. - session.add( - _db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00") - ) + session.add(_db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00")) # The newest commit's description is NULL — backfill should fill it in. session.add( _db_commit( @@ -248,9 +240,7 @@ def test_evidence_for_bulk_commit_uses_per_file_description( newest, oldest = list(Repository(temp_git_repo).commits) bulk_stub = "Bulk commit touching 50 files — per-file on demand." with get_session() as session: - session.add( - _db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00") - ) + session.add(_db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00")) # The newest commit is "bulk" (files_changed over the default # threshold of 30) and carries only the stub at the commit level. session.add( @@ -313,12 +303,8 @@ def test_evidence_for_merges_area_history_when_blame_is_thin( # NOT blamed because the predecessor file no longer exists at HEAD. predecessor_sha = "deadbeef" * 5 # 40 chars with get_session() as session: - session.add( - _db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00") - ) - session.add( - _db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00") - ) + session.add(_db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00")) + session.add(_db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00")) session.add( _db_commit( predecessor_sha, @@ -369,12 +355,8 @@ def test_evidence_for_does_not_duplicate_when_blame_and_area_agree( """A SHA produced by both blame and the area-history index appears once.""" newest, oldest = list(Repository(temp_git_repo).commits) with get_session() as session: - session.add( - _db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00") - ) - session.add( - _db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00") - ) + session.add(_db_commit(oldest.sha, committed_at="2026-01-01T00:00:00+00:00")) + session.add(_db_commit(newest.sha, committed_at="2026-02-01T00:00:00+00:00")) # Both blamed commits also have file-change rows for sample.py. session.add( CommitFileChange( diff --git a/tests/test_mcp_prompts.py b/tests/test_mcp_prompts.py index 3ae0947..c505023 100644 --- a/tests/test_mcp_prompts.py +++ b/tests/test_mcp_prompts.py @@ -37,16 +37,23 @@ def test_prompts_registered() -> None: # The targeted prompts surface four optional args; triage_commit has # a single required ``sha``. - pre_edit_args = {a.name: a.required for a in by_name["whygraph_pre_edit_brief"].arguments or []} + pre_edit_args = { + a.name: a.required for a in by_name["whygraph_pre_edit_brief"].arguments or [] + } assert pre_edit_args == { "path": False, "line_start": False, "line_end": False, "qualified_name": False, } - why_args = {a.name: a.required for a in by_name["whygraph_why_was_this_written"].arguments or []} + why_args = { + a.name: a.required + for a in by_name["whygraph_why_was_this_written"].arguments or [] + } assert why_args == pre_edit_args - triage_args = {a.name: a.required for a in by_name["whygraph_triage_commit"].arguments or []} + triage_args = { + a.name: a.required for a in by_name["whygraph_triage_commit"].arguments or [] + } assert triage_args == {"sha": True} diff --git a/tests/test_mcp_rationale.py b/tests/test_mcp_rationale.py index a19dc58..d20126b 100644 --- a/tests/test_mcp_rationale.py +++ b/tests/test_mcp_rationale.py @@ -85,9 +85,7 @@ def test_rationale_brief_returns_card( ) -> None: _seed_two_commits(temp_git_repo) monkeypatch.chdir(temp_git_repo) - monkeypatch.setattr( - "whygraph.mcp.rationale.RationaleGenerator", _FakeGenerator - ) + monkeypatch.setattr("whygraph.mcp.rationale.RationaleGenerator", _FakeGenerator) result = whygraph_rationale_brief(path="sample.py", line_start=1, line_end=3) @@ -108,9 +106,7 @@ def test_rationale_brief_errors_without_evidence( ) -> None: # No commits seeded — the blamed SHAs map to nothing in the DB. monkeypatch.chdir(temp_git_repo) - monkeypatch.setattr( - "whygraph.mcp.rationale.RationaleGenerator", _FakeGenerator - ) + monkeypatch.setattr("whygraph.mcp.rationale.RationaleGenerator", _FakeGenerator) with pytest.raises(WhyGraphError, match="no historical evidence"): whygraph_rationale_brief(path="sample.py", line_start=1, line_end=3) @@ -137,9 +133,7 @@ def generate( ) -> Rationale: raise AnalyzeError("model unavailable") - monkeypatch.setattr( - "whygraph.mcp.rationale.RationaleGenerator", _FailingGenerator - ) + monkeypatch.setattr("whygraph.mcp.rationale.RationaleGenerator", _FailingGenerator) with pytest.raises(WhyGraphError, match="rationale generation failed"): whygraph_rationale_brief(path="sample.py", line_start=1, line_end=3) diff --git a/tests/test_mcp_rationale_cache.py b/tests/test_mcp_rationale_cache.py index b1d4bc0..c087a21 100644 --- a/tests/test_mcp_rationale_cache.py +++ b/tests/test_mcp_rationale_cache.py @@ -114,9 +114,7 @@ def test_second_call_returns_cached( _seed_two_commits(temp_git_repo) monkeypatch.chdir(temp_git_repo) _CountingGenerator.reset() - monkeypatch.setattr( - "whygraph.mcp.rationale.RationaleGenerator", _CountingGenerator - ) + monkeypatch.setattr("whygraph.mcp.rationale.RationaleGenerator", _CountingGenerator) first = whygraph_rationale_brief(path="sample.py", line_start=1, line_end=3) second = whygraph_rationale_brief(path="sample.py", line_start=1, line_end=3) @@ -136,9 +134,7 @@ def test_new_commit_invalidates_cache( _seed_two_commits(temp_git_repo) monkeypatch.chdir(temp_git_repo) _CountingGenerator.reset() - monkeypatch.setattr( - "whygraph.mcp.rationale.RationaleGenerator", _CountingGenerator - ) + monkeypatch.setattr("whygraph.mcp.rationale.RationaleGenerator", _CountingGenerator) first = whygraph_rationale_brief(path="sample.py", line_start=1, line_end=3) _add_third_commit(temp_git_repo) diff --git a/tests/test_mcp_resources.py b/tests/test_mcp_resources.py index 4515752..726d84b 100644 --- a/tests/test_mcp_resources.py +++ b/tests/test_mcp_resources.py @@ -201,14 +201,10 @@ def test_pr_resource_includes_full_blobs( whygraph_db_initialized: Path, ) -> None: """A direct PR read decodes ``commit_titles`` and ``comments`` as lists.""" - commit_titles = json.dumps( - [{"oid": "c" * 40, "messageHeadline": "first"}] - ) + commit_titles = json.dumps([{"oid": "c" * 40, "messageHeadline": "first"}]) comments = json.dumps([{"author": "alice", "body": "lgtm"}]) with get_session() as session: - session.add( - _db_pr(number=42, commit_titles=commit_titles, comments=comments) - ) + session.add(_db_pr(number=42, commit_titles=commit_titles, comments=comments)) result = _pr_resource(42) pr = result["pull_request"] @@ -237,9 +233,7 @@ def test_pr_resource_link_kind_other_than_closes_is_ignored( with get_session() as session: session.add(_db_pr(number=12)) session.add(_db_issue(number=200)) - session.add( - PRIssueLink(pr_number=12, issue_number=200, link_kind="mentions") - ) + session.add(PRIssueLink(pr_number=12, issue_number=200, link_kind="mentions")) result = _pr_resource(12) assert result["closing_issues"] == [] diff --git a/tests/test_preflight.py b/tests/test_preflight.py index b7c30a2..b2378c4 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -10,7 +10,6 @@ import subprocess from pathlib import Path -from typing import Callable import pytest @@ -59,9 +58,7 @@ def test_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: run_preflight(tmp_path) -def test_git_missing_raises( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: +def test_git_missing_raises(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _git_repo(tmp_path, remote_url=None) _patch_which(monkeypatch, missing={"git"}) monkeypatch.setenv("ANTHROPIC_API_KEY", "x") diff --git a/tests/test_scan_analyze_crawler.py b/tests/test_scan_analyze_crawler.py index 7af16f3..c6ba48a 100644 --- a/tests/test_scan_analyze_crawler.py +++ b/tests/test_scan_analyze_crawler.py @@ -194,9 +194,7 @@ def test_bulk_commits_get_stub_without_llm_call( assert model is None # NULL model = "not LLM-generated" -def test_skips_already_described_commits( - isolated_db: Path, repo_path: Path -) -> None: +def test_skips_already_described_commits(isolated_db: Path, repo_path: Path) -> None: commits = _commits(repo_path) already = commits[0].sha _insert(commits, described=(already,)) @@ -212,9 +210,7 @@ def test_skips_already_described_commits( assert len(descriptor.seen) == len(commits) - 1 -def test_skips_commit_with_empty_diff( - isolated_db: Path, repo_path: Path -) -> None: +def test_skips_commit_with_empty_diff(isolated_db: Path, repo_path: Path) -> None: _git(repo_path, "commit", "-q", "--allow-empty", "-m", "empty") commits = _commits(repo_path) empty_sha = commits[0].sha # newest = the empty commit diff --git a/tests/test_services_codegraph.py b/tests/test_services_codegraph.py index e0ad816..d999d65 100644 --- a/tests/test_services_codegraph.py +++ b/tests/test_services_codegraph.py @@ -50,9 +50,7 @@ def test_for_repository_honours_codegraph_db_override( ) -> None: # The explicit override (a `whygraph.toml` `codegraph_db` entry) wins over # the /.codegraph/... default — note `tmp_path` has no `.codegraph/`. - with CodeGraph.for_repository( - tmp_path, codegraph_db=fake_codegraph_db - ) as graph: + with CodeGraph.for_repository(tmp_path, codegraph_db=fake_codegraph_db) as graph: assert graph.symbol("pkg.a") is not None diff --git a/tests/test_services_git_file_change.py b/tests/test_services_git_file_change.py index 8ce4768..4e61d26 100644 --- a/tests/test_services_git_file_change.py +++ b/tests/test_services_git_file_change.py @@ -11,7 +11,6 @@ import subprocess from pathlib import Path -import pytest from whygraph.services.git import FileChange, Repository from whygraph.services.git.file_change import ( @@ -47,9 +46,7 @@ def test_parse_raw_line_modification() -> None: def test_parse_raw_line_rename_with_similarity() -> None: - rec = _parse_raw_line( - ":100644 100644 abc1234 def5678 R98\tsrc/old.py\tsrc/new.py" - ) + rec = _parse_raw_line(":100644 100644 abc1234 def5678 R98\tsrc/old.py\tsrc/new.py") assert rec == { "change_type": "R", "renamed_from": "src/old.py", @@ -123,9 +120,7 @@ def test_from_diff_tree_resolves_rename_numstat_via_brace_form() -> None: def test_from_diff_tree_treats_binary_numstat_as_zero() -> None: stdout = ( - ":100644 100644 abc1234 def5678 M\tassets/img.png\n" - "\n" - "-\t-\tassets/img.png\n" + ":100644 100644 abc1234 def5678 M\tassets/img.png\n\n-\t-\tassets/img.png\n" ) changes = FileChange.from_diff_tree(stdout) diff --git a/tests/test_services_git_origin_remote.py b/tests/test_services_git_origin_remote.py index 0652bea..23f889a 100644 --- a/tests/test_services_git_origin_remote.py +++ b/tests/test_services_git_origin_remote.py @@ -23,7 +23,9 @@ def _repo_with_remotes(tmp_path: Path) -> Path: # rewrite (commonly https://github.com → git@github.com) can't change # the stored URL out from under the assertions. _git(tmp_path, "init", "-q", "-b", "main") - _git(tmp_path, "remote", "add", "origin", "https://example.test/acme/origin-repo.git") + _git( + tmp_path, "remote", "add", "origin", "https://example.test/acme/origin-repo.git" + ) _git( tmp_path, "remote", diff --git a/tests/test_services_llm_claude_cli.py b/tests/test_services_llm_claude_cli.py index d657670..524a0a6 100644 --- a/tests/test_services_llm_claude_cli.py +++ b/tests/test_services_llm_claude_cli.py @@ -87,7 +87,9 @@ def fake_run(cmd, **_): assert "--system-prompt" not in captured["cmd"] -def test_complete_strips_anthropic_api_key_by_default(monkeypatch: pytest.MonkeyPatch) -> None: +def test_complete_strips_anthropic_api_key_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-from-env") captured: dict = {} @@ -101,7 +103,9 @@ def fake_run(cmd, *, env, **_): assert "ANTHROPIC_API_KEY" not in captured["env"] -def test_complete_sets_anthropic_api_key_when_provided(monkeypatch: pytest.MonkeyPatch) -> None: +def test_complete_sets_anthropic_api_key_when_provided( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) captured: dict = {} diff --git a/tests/test_services_llm_types.py b/tests/test_services_llm_types.py index dcac6f8..71d7bd6 100644 --- a/tests/test_services_llm_types.py +++ b/tests/test_services_llm_types.py @@ -51,9 +51,7 @@ def test_completion_request_of_with_system_prepends_system_message() -> None: def test_completion_request_of_forwards_overrides() -> None: - req = CompletionRequest.of( - "x", max_tokens=128, temperature=0.2, timeout_sec=15 - ) + req = CompletionRequest.of("x", max_tokens=128, temperature=0.2, timeout_sec=15) assert req.max_tokens == 128 assert req.temperature == 0.2 assert req.timeout_sec == 15 diff --git a/tests/test_shell_command.py b/tests/test_shell_command.py index 263cd9b..dd27815 100644 --- a/tests/test_shell_command.py +++ b/tests/test_shell_command.py @@ -106,9 +106,7 @@ def test_run_all_argv_path_unchanged() -> None: def test_run_all_command_path_returns_parsed_list_in_input_order() -> None: """A ShellCommand batch returns parser outputs in the same order as input.""" - results = Shell().run_all( - [_EchoCommand("a"), _EchoCommand("b"), _EchoCommand("c")] - ) + results = Shell().run_all([_EchoCommand("a"), _EchoCommand("b"), _EchoCommand("c")]) assert results == ["a", "b", "c"] assert all(isinstance(r, str) for r in results) diff --git a/uv.lock b/uv.lock index 3a5f38b..a6d7ba1 100644 --- a/uv.lock +++ b/uv.lock @@ -1107,6 +1107,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0" @@ -1438,6 +1463,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -1455,4 +1481,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.8" }, +]