diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e698ff6e..a38ba5b6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -18,6 +18,7 @@ Makefile は `nix develop --command bash -c "..."` でラップ済み。AI は |---|---| | CI 相当一括 | `make ci` (= `lint + test + build-web`) | | Backend lint | `make lint-backend` | +| Backend 型チェック | `make typecheck-backend` (pyright。`make lint` に含まれる) | | Backend test | `make test-backend` | | Frontend lint | `make lint-web` | | Frontend test | `make test-web` | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec4ca337..98b59e41 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -193,6 +193,13 @@ jobs: working-directory: backend run: uv run ruff check app tests alembic_migrations + # 型チェック(pyright)。pyright は pyproject [tool.pyright] の venv=".venv" を参照するため、 + # 直前の uv pip install で .venv に依存が入っている必要がある(このジョブで担保済み)。 + # バージョンは Makefile の typecheck-backend と揃えてピン留めする(ローカル/CI の drift 防止)。 + - name: Type check backend (pyright) + working-directory: backend + run: uvx pyright@1.1.411 app tests alembic_migrations + - name: Run backend tests working-directory: backend run: uv run pytest -q tests diff --git a/Makefile b/Makefile index 12dd3dd8..013f8f2a 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ setup install-hooks install-backend install-web generate-keys \ dev dev-build dev-down dev-amd64 dev-amd64-build dev-web preview-web dev-proxy dev-proxy-only stripe-webhook \ test test-backend test-web \ - lint lint-backend lint-web lint-web-messages lint-env-keys lint-fix \ + lint lint-backend typecheck-backend lint-web lint-web-messages lint-env-keys lint-fix \ format format-check \ ci \ dupe-check dupe-check-html dupe-clean \ @@ -40,6 +40,7 @@ help: @echo " test-web Frontend: vitest" @echo " lint 全リント (backend + web)" @echo " lint-backend Backend: ruff check" + @echo " typecheck-backend Backend: pyright 型チェック" @echo " lint-web Frontend: eslint" @echo " lint-web-messages Frontend: setError等にリテラル日本語が渡っていないか検知" @echo " lint-env-keys env名/エラーコードの SSoT drift を検知 (env_keys.py↔compose, errors.py↔errorCodes.ts)" @@ -141,11 +142,17 @@ test-backend: test-web: nix develop --command bash -c "cd web && npm test" -lint: lint-backend lint-web lint-web-messages lint-env-keys +lint: lint-backend typecheck-backend lint-web lint-web-messages lint-env-keys lint-backend: nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check app tests alembic_migrations" +# Backend 型チェック(pyright)。pyproject [tool.pyright] の venv=".venv" を参照するため +# 事前に make install-backend で backend/.venv に依存を入れておくこと。 +# バージョンは CI(.github/workflows/test.yml)と揃えてピン留めする(drift 防止)。 +typecheck-backend: + nix develop --command bash -c "cd backend && uvx pyright@1.1.411 app tests alembic_migrations" + lint-web: nix develop --command bash -c "cd web && npm run lint" diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index abfb78f7..6f64884c 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -1,4 +1,5 @@ import os +from typing import Literal, cast from urllib.parse import urlparse from . import env_keys @@ -93,7 +94,7 @@ def get_cookie_secure() -> bool: return True -def get_cookie_samesite() -> str: +def get_cookie_samesite() -> Literal["lax", "strict", "none"]: default = "lax" origins = get_cors_origins() if origins and not all(_is_loopback_origin(origin) for origin in origins): @@ -102,7 +103,8 @@ def get_cookie_samesite() -> str: value = os.getenv(env_keys.COOKIE_SAMESITE, default).strip().lower() if value not in {"lax", "strict", "none"}: raise RuntimeError("COOKIE_SAMESITE must be one of: lax, strict, none") - return value + # 上の検証で値域は保証済み。set_cookie の samesite 引数が要求する Literal へ絞る。 + return cast(Literal["lax", "strict", "none"], value) def get_admin_token() -> str: diff --git a/backend/app/repositories/master_data.py b/backend/app/repositories/master_data.py index 17591380..f8501a77 100644 --- a/backend/app/repositories/master_data.py +++ b/backend/app/repositories/master_data.py @@ -23,14 +23,17 @@ def list_by_category(self, category: str) -> list[MTechnologyStack]: ) return list(self.db.scalars(statement).all()) - def create(self, category: str, name: str, sort_order: int = 0) -> MTechnologyStack: + # category 列を持つため基底の create/update をあえて拡張シグネチャで上書きする。 + # MTechnologyStackRepository 型で直接呼ぶ箇所のみが使い、基底経由の多態呼び出しは無いため + # Liskov 非互換は意図的(reportIncompatibleMethodOverride を局所抑制)。 + def create(self, category: str, name: str, sort_order: int = 0) -> MTechnologyStack: # pyright: ignore[reportIncompatibleMethodOverride] item = MTechnologyStack(category=category, name=name, sort_order=sort_order) self.db.add(item) self.db.commit() self.db.refresh(item) return item - def update( + def update( # pyright: ignore[reportIncompatibleMethodOverride] self, item_id: str, category: str, name: str, sort_order: int = 0 ) -> MTechnologyStack | None: item = self.db.scalar(select(MTechnologyStack).where(MTechnologyStack.id == item_id)) diff --git a/backend/app/repositories/notification.py b/backend/app/repositories/notification.py index 3182f5fd..1ff641b9 100644 --- a/backend/app/repositories/notification.py +++ b/backend/app/repositories/notification.py @@ -1,6 +1,9 @@ """通知リポジトリ。""" +from typing import Any, cast + from sqlalchemy import select, update +from sqlalchemy.engine import CursorResult from sqlalchemy.orm import Session from ..models.notification import Notification @@ -41,7 +44,7 @@ def mark_read(self, notification_id: str) -> bool: .where(Notification.id == notification_id, Notification.user_id == self.user_id) .values(is_read=True) ) - result = self.db.execute(stmt) + result = cast("CursorResult[Any]", self.db.execute(stmt)) self.db.commit() return result.rowcount > 0 @@ -52,7 +55,7 @@ def mark_all_read(self) -> int: .where(Notification.user_id == self.user_id, Notification.is_read.is_(False)) .values(is_read=True) ) - result = self.db.execute(stmt) + result = cast("CursorResult[Any]", self.db.execute(stmt)) self.db.commit() return result.rowcount diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index e13e74d9..916af584 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -1,3 +1,5 @@ +from typing import Any + from sqlalchemy.orm import selectinload from ..core.date_utils import parse_year_month @@ -39,7 +41,7 @@ class ResumeRepository(SingleUserDocumentRepository): selectinload(Resume.qualification_rows), ) - def _apply_payload(self, entity: Resume, payload: dict[str, object]) -> None: + def _apply_payload(self, entity: Resume, payload: dict[str, Any]) -> None: entity.full_name = payload["full_name"] entity.email = payload["email"] entity.github_url = payload.get("github_url", "") @@ -67,7 +69,7 @@ def _apply_payload(self, entity: Resume, payload: dict[str, object]) -> None: for index, item in enumerate(sorted_qualifications) ] - def _build_experience_row(self, index: int, payload: dict[str, object]) -> ResumeExperience: + def _build_experience_row(self, index: int, payload: dict[str, Any]) -> ResumeExperience: return ResumeExperience( sort_order=index, company=payload["company"], @@ -88,7 +90,7 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum ], ) - def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient: + def _build_client_row(self, index: int, payload: dict[str, Any]) -> ResumeClient: projects = list(payload.get("projects", [])) sorted_projects = sorted(projects, key=self._project_sort_key) vacation_start = payload.get("vacation_start_date") or "" @@ -109,7 +111,7 @@ def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeCli ) @staticmethod - def _project_sort_key(proj: dict[str, object]) -> tuple: + def _project_sort_key(proj: dict[str, Any]) -> tuple: """複数期間を持つプロジェクトを sort_by_period_desc と同じ降順ロジックでソートする。""" from datetime import date as _date @@ -131,10 +133,10 @@ def _parse(val: object) -> _date | None: if is_any_current: return (0, _date.max - effective_start) ends = [_parse(p.get("end_date")) for p in periods if p.get("end_date")] - effective_end = max(ends, default=_date.min) + effective_end = max((e for e in ends if e), default=_date.min) return (1, _date.max - effective_end, _date.max - effective_start) - def _build_project_row(self, index: int, payload: dict[str, object]) -> ResumeProject: + def _build_project_row(self, index: int, payload: dict[str, Any]) -> ResumeProject: team = payload.get("team", {}) periods = list(payload.get("periods", [])) return ResumeProject( diff --git a/backend/app/routers/blog/accounts.py b/backend/app/routers/blog/accounts.py index 250a6de5..11643148 100644 --- a/backend/app/routers/blog/accounts.py +++ b/backend/app/routers/blog/accounts.py @@ -75,11 +75,6 @@ async def add_account( status_code=502, detail=get_error("blog.account_check_failed"), ) from exc - except BlogAccountNotFoundError as exc: - raise HTTPException( - status_code=404, - detail=get_error("blog.account_not_found"), - ) from exc @router.patch("/accounts/{platform}", response_model=BlogAccountResponse) diff --git a/backend/app/routers/github_link/endpoints.py b/backend/app/routers/github_link/endpoints.py index d08fec7a..8a0c04a2 100644 --- a/backend/app/routers/github_link/endpoints.py +++ b/backend/app/routers/github_link/endpoints.py @@ -22,6 +22,7 @@ from ...schemas.github_link import ( CachedGitHubLinkResponse, GitHubLinkRequest, + GitHubLinkResponse, ProgressResponse, ) from ...schemas.github_skill import GitHubSkillsResponse @@ -69,8 +70,13 @@ def get_cache( cache = GitHubLinkCacheRepository(db).get_by_user(user.id) if not cache: return CachedGitHubLinkResponse() + # cache.result は GitHubLinkResponse(...).model_dump() を保存した JSON(dict | None)。 + # Pydantic v2 は dict を受け取ると検証済みモデルへコアース可能なので validate で型を絞る。 + result = ( + GitHubLinkResponse.model_validate(cache.result) if cache.result is not None else None + ) return CachedGitHubLinkResponse( - result=cache.result, + result=result, status=cache.status, error_message=cache.error_message, error_code=resolve_async_error_code(cache.error_message), diff --git a/backend/app/services/agent/chat_service.py b/backend/app/services/agent/chat_service.py index 0d2af8ef..7d4c9b64 100644 --- a/backend/app/services/agent/chat_service.py +++ b/backend/app/services/agent/chat_service.py @@ -21,6 +21,7 @@ AgentOperation, AgentProjectContext, ExperienceTarget, + ProjectTarget, ) from .llm.base import LLMError from .llm.factory import get_llm_client @@ -193,8 +194,8 @@ def _resolve_target_experience(request: AgentChatRequest) -> AgentExperienceCont def _resolve_target_project(request: AgentChatRequest) -> AgentProjectContext: """target のインデックスから対象プロジェクトを取り出す。範囲外は専用例外。""" target = request.target - # scope=project のとき target は schema で必須検証済み - assert target is not None + # scope=project のとき target は schema で ProjectTarget(client_index/project_index 必須)に検証済み + assert isinstance(target, ProjectTarget) try: return ( request.resume.experiences[target.experience_index] diff --git a/backend/app/services/agent/llm/anthropic_client.py b/backend/app/services/agent/llm/anthropic_client.py index 8cb71992..5623da8b 100644 --- a/backend/app/services/agent/llm/anthropic_client.py +++ b/backend/app/services/agent/llm/anthropic_client.py @@ -6,9 +6,10 @@ """ import json +from typing import Any, cast import anthropic -from anthropic import AsyncAnthropicVertex +from anthropic import AsyncAnthropicVertex # pyright: ignore[reportPrivateImportUsage] from ....core import settings from ..output_schema import TOOL_NAME, build_tool_definition @@ -47,10 +48,11 @@ async def generate( max_tokens=_MAX_TOKENS, temperature=_TEMPERATURE, system=system_prompt, - messages=messages, + # 履歴契約の dict 形は SDK の MessageParam と互換だが静的には別型のため境界で cast。 + messages=cast(Any, messages), # tool use 強制で出力構造をスキーマに従わせる(JSON mode は使わない)。 # maxLength は API では強制されないため、上限超過は呼び出し側で破棄する - tools=[build_tool_definition(output_schema)], + tools=[cast(Any, build_tool_definition(output_schema))], tool_choice={"type": "tool", "name": TOOL_NAME}, ) except ( diff --git a/backend/app/services/agent/llm/google_client.py b/backend/app/services/agent/llm/google_client.py index a7e125ba..6f93335f 100644 --- a/backend/app/services/agent/llm/google_client.py +++ b/backend/app/services/agent/llm/google_client.py @@ -7,6 +7,8 @@ スキーマに従う JSON のシリアライズ文字列として返す。 """ +from typing import Any, cast + from google import genai from google.genai import errors as genai_errors from google.genai import types as genai_types @@ -59,7 +61,8 @@ async def generate( ) try: response = await self._client.aio.models.generate_content( - model=model_id, contents=contents, config=config + # list[Content] は ContentListUnion に含まれるが静的には別型のため境界で cast。 + model=model_id, contents=cast(Any, contents), config=config ) except genai_errors.APIError as exc: raise wrap_api_error("Google", exc) from exc diff --git a/backend/app/services/agent/llm/openai_client.py b/backend/app/services/agent/llm/openai_client.py index 09608caf..c22ad359 100644 --- a/backend/app/services/agent/llm/openai_client.py +++ b/backend/app/services/agent/llm/openai_client.py @@ -5,6 +5,8 @@ 応答テキストは Anthropic / Gemini と同じくスキーマに従う JSON のシリアライズ文字列で返す。 """ +from typing import Any, cast + import openai from ....core import settings @@ -37,7 +39,8 @@ async def generate( response = await self._client.chat.completions.create( model=model_id, temperature=_TEMPERATURE, - messages=full_messages, + # role/content の dict 形は SDK の ChatCompletionMessageParam と互換だが静的には別型。 + messages=cast(Any, full_messages), response_format={ "type": "json_schema", "json_schema": { diff --git a/backend/app/services/agent/output_schema.py b/backend/app/services/agent/output_schema.py index ec66b08f..b4884b23 100644 --- a/backend/app/services/agent/output_schema.py +++ b/backend/app/services/agent/output_schema.py @@ -12,6 +12,8 @@ maxLength は JSON Schema 仕様どおり Unicode 文字数(日本語の len() と一致)。 """ +from typing import cast + TOOL_NAME = "propose_revision" TOOL_DESCRIPTION = "職務経歴書フィールドの改善案・説明・次の依頼候補を返す" @@ -103,7 +105,8 @@ def to_portable_schema(schema: dict, *, drop_additional_properties: bool = False strip_keys = {"maxLength", "maxItems"} if drop_additional_properties: strip_keys = strip_keys | {"additionalProperties"} - portable = _strip_constraints(schema, strip_keys) + # _strip_constraints は object を返すが、dict 入力に対しては dict を返す(下で .get / 返却に使う)。 + portable = cast(dict, _strip_constraints(schema, strip_keys)) operations = portable.get("properties", {}).get("operations", {}) items = operations.get("items", {}) branches = items.get("oneOf") diff --git a/backend/app/services/intelligence/skills/imports/go.py b/backend/app/services/intelligence/skills/imports/go.py index dbf72347..a9ac1339 100644 --- a/backend/app/services/intelligence/skills/imports/go.py +++ b/backend/app/services/intelligence/skills/imports/go.py @@ -17,8 +17,8 @@ class GoImportScanner: - extensions = (".go",) - ecosystem = "go" + extensions: tuple[str, ...] = (".go",) + ecosystem: str = "go" def scan(self, content: str) -> set[str]: paths: set[str] = set() diff --git a/backend/app/services/intelligence/skills/imports/js_ts.py b/backend/app/services/intelligence/skills/imports/js_ts.py index 90439958..d5848f29 100644 --- a/backend/app/services/intelligence/skills/imports/js_ts.py +++ b/backend/app/services/intelligence/skills/imports/js_ts.py @@ -42,8 +42,17 @@ def _package_of(spec: str) -> str | None: class JsTsImportScanner: - extensions = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts") - ecosystem = "npm" + extensions: tuple[str, ...] = ( + ".js", + ".jsx", + ".ts", + ".tsx", + ".mjs", + ".cjs", + ".mts", + ".cts", + ) + ecosystem: str = "npm" def scan(self, content: str) -> set[str]: names: set[str] = set() diff --git a/backend/app/services/intelligence/skills/imports/python.py b/backend/app/services/intelligence/skills/imports/python.py index 58dc751a..e3e855ce 100644 --- a/backend/app/services/intelligence/skills/imports/python.py +++ b/backend/app/services/intelligence/skills/imports/python.py @@ -20,8 +20,8 @@ def _top_level(module: str) -> str: class PythonImportScanner: - extensions = (".py", ".pyi") - ecosystem = "pypi" + extensions: tuple[str, ...] = (".py", ".pyi") + ecosystem: str = "pypi" def scan(self, content: str) -> set[str]: return {_top_level(m) for m in _IMPORT_RE.findall(content)} diff --git a/backend/app/services/intelligence/skills/imports/rust.py b/backend/app/services/intelligence/skills/imports/rust.py index 7660ad7c..596876e9 100644 --- a/backend/app/services/intelligence/skills/imports/rust.py +++ b/backend/app/services/intelligence/skills/imports/rust.py @@ -20,8 +20,8 @@ class RustImportScanner: - extensions = (".rs",) - ecosystem = "cargo" + extensions: tuple[str, ...] = (".rs",) + ecosystem: str = "cargo" def scan(self, content: str) -> set[str]: names = set(_USE_RE.findall(content)) | set(_EXTERN_RE.findall(content)) diff --git a/backend/app/services/intelligence/skills/manifests/cargo_toml.py b/backend/app/services/intelligence/skills/manifests/cargo_toml.py index da88be2c..79ce7b64 100644 --- a/backend/app/services/intelligence/skills/manifests/cargo_toml.py +++ b/backend/app/services/intelligence/skills/manifests/cargo_toml.py @@ -16,8 +16,8 @@ class CargoTomlParser: - filenames = ("Cargo.toml",) - ecosystem = "cargo" + filenames: tuple[str, ...] = ("Cargo.toml",) + ecosystem: str = "cargo" def parse(self, content: str) -> list[PackageDeclaration]: try: diff --git a/backend/app/services/intelligence/skills/manifests/go_mod.py b/backend/app/services/intelligence/skills/manifests/go_mod.py index 3fdf4fde..055a9b4f 100644 --- a/backend/app/services/intelligence/skills/manifests/go_mod.py +++ b/backend/app/services/intelligence/skills/manifests/go_mod.py @@ -15,8 +15,8 @@ class GoModParser: - filenames = ("go.mod",) - ecosystem = "go" + filenames: tuple[str, ...] = ("go.mod",) + ecosystem: str = "go" def parse(self, content: str) -> list[PackageDeclaration]: declarations: list[PackageDeclaration] = [] diff --git a/backend/app/services/intelligence/skills/manifests/package_json.py b/backend/app/services/intelligence/skills/manifests/package_json.py index e8e3290c..799e812d 100644 --- a/backend/app/services/intelligence/skills/manifests/package_json.py +++ b/backend/app/services/intelligence/skills/manifests/package_json.py @@ -19,8 +19,8 @@ class PackageJsonParser: - filenames = ("package.json",) - ecosystem = "npm" + filenames: tuple[str, ...] = ("package.json",) + ecosystem: str = "npm" def parse(self, content: str) -> list[PackageDeclaration]: try: diff --git a/backend/app/services/intelligence/skills/manifests/pyproject.py b/backend/app/services/intelligence/skills/manifests/pyproject.py index df17bc99..b59a3fde 100644 --- a/backend/app/services/intelligence/skills/manifests/pyproject.py +++ b/backend/app/services/intelligence/skills/manifests/pyproject.py @@ -18,8 +18,8 @@ class PyprojectParser: - filenames = ("pyproject.toml",) - ecosystem = "pypi" + filenames: tuple[str, ...] = ("pyproject.toml",) + ecosystem: str = "pypi" def parse(self, content: str) -> list[PackageDeclaration]: try: diff --git a/backend/app/services/intelligence/skills/manifests/requirements_txt.py b/backend/app/services/intelligence/skills/manifests/requirements_txt.py index 4a6f1c73..7e892d3b 100644 --- a/backend/app/services/intelligence/skills/manifests/requirements_txt.py +++ b/backend/app/services/intelligence/skills/manifests/requirements_txt.py @@ -9,8 +9,8 @@ class RequirementsTxtParser: - filenames = ("requirements.txt",) - ecosystem = "pypi" + filenames: tuple[str, ...] = ("requirements.txt",) + ecosystem: str = "pypi" def parse(self, content: str) -> list[PackageDeclaration]: declarations: list[PackageDeclaration] = [] diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 4e967b67..9c7f1857 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -391,5 +391,8 @@ def build_resume_pdf(resume: dict) -> bytes: f"{html_body}" ) + # target 未指定の write_pdf は bytes を返す(None は出力先を渡したときのみ)。 pdf_bytes = weasyprint.HTML(string=full_html).write_pdf() + if pdf_bytes is None: + raise RuntimeError("PDF の生成に失敗しました") return pdf_bytes diff --git a/backend/tests/blog/test_accounts.py b/backend/tests/blog/test_accounts.py index ca28fe8b..50e92500 100644 --- a/backend/tests/blog/test_accounts.py +++ b/backend/tests/blog/test_accounts.py @@ -167,6 +167,7 @@ def test_update_blog_account_resets_articles_and_sync_state( from app.repositories import BlogArticleRepository, UserRepository user = UserRepository(db_session).get_by_username("testuser") + assert user is not None account_repo = BlogAccountRepository(db_session, user.id) account = account_repo.get_by_id(account_id) assert account is not None @@ -249,6 +250,7 @@ def test_list_blog_articles_returns_platform_and_tags( from app.repositories import BlogArticleRepository, UserRepository user = UserRepository(db_session).get_by_username("testuser") + assert user is not None repo = BlogArticleRepository(db_session, user.id) repo.upsert_many( [ diff --git a/backend/tests/blog/test_sync.py b/backend/tests/blog/test_sync.py index deacadf0..0d7021b7 100644 --- a/backend/tests/blog/test_sync.py +++ b/backend/tests/blog/test_sync.py @@ -54,6 +54,7 @@ def test_sync_account_normalizes_saved_username_and_removes_stale_articles( from app.repositories import BlogArticleRepository, UserRepository user = UserRepository(db_session).get_by_username("testuser") + assert user is not None account = BlogAccount( user_id=user.id, platform="zenn", @@ -125,6 +126,7 @@ def test_upsert_articles_no_duplicates(client: TestClient, db_session: Session) from app.repositories import BlogArticleRepository, UserRepository user = UserRepository(db_session).get_by_username("testuser") + assert user is not None repo = BlogArticleRepository(db_session, user.id) articles = [ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5a4aaab0..a68f53b7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -150,7 +150,7 @@ def _override_get_db(): module: module.execute_task for module in (_worker, _internal_router, _tasks_local) } for module in originals: - module.execute_task = mock_execute_task + setattr(module, "execute_task", mock_execute_task) limiter.reset() with TestClient(app) as c: @@ -158,7 +158,7 @@ def _override_get_db(): yield c app.dependency_overrides.clear() for module, original in originals.items(): - module.execute_task = original + setattr(module, "execute_task", original) def make_resume_payload(**overrides) -> dict: diff --git a/backend/tests/security/test_mass_assignment.py b/backend/tests/security/test_mass_assignment.py index e3addd91..f0af7cef 100644 --- a/backend/tests/security/test_mass_assignment.py +++ b/backend/tests/security/test_mass_assignment.py @@ -20,9 +20,11 @@ def test_resume_create_does_not_transfer_ownership(client: TestClient) -> None: db = client._db_session UserRepository(db).create("victim-a", email="victim-a@example.com") victim = UserRepository(db).get_by_username("victim-a") + assert victim is not None headers = auth_header(client, "attacker-a") attacker = UserRepository(db).get_by_username("attacker-a") + assert attacker is not None payload = make_resume_payload(user_id=victim.id) resp = client.post("/api/resumes", json=payload, headers=headers) @@ -41,9 +43,11 @@ def test_resume_update_does_not_transfer_ownership(client: TestClient) -> None: db = client._db_session UserRepository(db).create("victim-b", email="victim-b@example.com") victim = UserRepository(db).get_by_username("victim-b") + assert victim is not None headers = auth_header(client, "attacker-b") attacker = UserRepository(db).get_by_username("attacker-b") + assert attacker is not None created = client.post("/api/resumes", json=make_resume_payload(), headers=headers) assert created.status_code == 201 @@ -67,9 +71,11 @@ def test_blog_account_create_does_not_transfer_ownership(client: TestClient) -> db = client._db_session UserRepository(db).create("victim-blog-a", email="victim-blog-a@example.com") victim = UserRepository(db).get_by_username("victim-blog-a") + assert victim is not None headers = auth_header(client, "attacker-blog-a") attacker = UserRepository(db).get_by_username("attacker-blog-a") + assert attacker is not None with patch(_ACCOUNT_VERIFY_PATCH, new_callable=AsyncMock, return_value=True): resp = client.post( @@ -91,9 +97,11 @@ def test_blog_account_update_does_not_transfer_ownership(client: TestClient) -> db = client._db_session UserRepository(db).create("victim-blog-b", email="victim-blog-b@example.com") victim = UserRepository(db).get_by_username("victim-blog-b") + assert victim is not None headers = auth_header(client, "attacker-blog-b") attacker = UserRepository(db).get_by_username("attacker-blog-b") + assert attacker is not None with patch(_ACCOUNT_VERIFY_PATCH, new_callable=AsyncMock, return_value=True), patch( _SERVICE_VERIFY_PATCH, diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py index 30f5dc44..cfcfffdc 100644 --- a/backend/tests/test_billing.py +++ b/backend/tests/test_billing.py @@ -4,9 +4,10 @@ 実 DB(テスト用 SQLite セッション)を通す。 """ -from typing import get_args +from typing import cast, get_args import pytest +import stripe from app.core import settings from app.models import AgentUsageLog from app.repositories import BillingRepository, UserRepository @@ -759,13 +760,14 @@ def test_record_stripe_purchase_grants_once(client: TestClient) -> None: def test_extract_completed_purchase_skips_unpaid() -> None: """未払い(payment_status != paid)のイベントは付与対象にしない。""" event = _completed_event("u1", 1_000, payment_status="unpaid") - assert stripe_service.extract_completed_purchase(event) is None + # stripe.Event は dict ライクに振る舞う(helper は同等 dict を返す)。境界で型を合わせる。 + assert stripe_service.extract_completed_purchase(cast(stripe.Event, event)) is None def test_extract_completed_purchase_skips_other_event_types() -> None: """checkout.session.completed 以外のイベントは付与対象にしない。""" event = _completed_event("u1", 1_000, event_type="payment_intent.created") - assert stripe_service.extract_completed_purchase(event) is None + assert stripe_service.extract_completed_purchase(cast(stripe.Event, event)) is None # --- 統合テスト(Checkout セッション作成) --- diff --git a/backend/tests/test_database_libsql_binary.py b/backend/tests/test_database_libsql_binary.py index ee3d7115..30e2de71 100644 --- a/backend/tests/test_database_libsql_binary.py +++ b/backend/tests/test_database_libsql_binary.py @@ -42,4 +42,6 @@ def test_standard_sqlite_dialect_unaffected(): engine = create_engine("sqlite:///:memory:", poolclass=NullPool) assert engine.dialect.driver == "pysqlite" # pysqlite は標準で Binary を提供する - assert callable(engine.dialect.dbapi.Binary) + dbapi = engine.dialect.dbapi + assert dbapi is not None + assert callable(dbapi.Binary) diff --git a/backend/tests/test_github_skills_api.py b/backend/tests/test_github_skills_api.py index f071e09e..da75fcdb 100644 --- a/backend/tests/test_github_skills_api.py +++ b/backend/tests/test_github_skills_api.py @@ -9,7 +9,9 @@ def _user_id(client, username: str) -> str: - return UserRepository(client._db_session).get_by_username(username).id + user = UserRepository(client._db_session).get_by_username(username) + assert user is not None + return user.id def _sample_detected() -> list[DetectedSkill]: diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py index 26971e1d..05fb0a99 100644 --- a/backend/tests/test_pdf_generator.py +++ b/backend/tests/test_pdf_generator.py @@ -16,7 +16,8 @@ def test_format_period_for_current() -> None: - period = _format_period("2022-04", None, True) + # 在籍中は end_date を "" で受ける契約(format_period の docstring 準拠)。 + period = _format_period("2022-04", "", True) assert "現在" in period diff --git a/backend/tests/test_retry_flow.py b/backend/tests/test_retry_flow.py index dedbae7c..d666258a 100644 --- a/backend/tests/test_retry_flow.py +++ b/backend/tests/test_retry_flow.py @@ -327,6 +327,7 @@ def test_intelligence_retry_resets_cache(self, client: TestClient): headers = auth_header(client, "retry-intel-user", github_id=999) db = client._db_session user = UserRepository(db).get_by_username("retry-intel-user") + assert user is not None cache = GitHubLinkCache( user_id=user.id, @@ -359,6 +360,7 @@ def test_retry_returns_409_when_not_dead_letter(self, client: TestClient, status headers = auth_header(client, username, github_id=999) db = client._db_session user = UserRepository(db).get_by_username(username) + assert user is not None cache = GitHubLinkCache(user_id=user.id, status=status) db.add(cache) @@ -377,6 +379,7 @@ def test_retry_returns_409_on_concurrent_reset_race(self, client: TestClient): headers = auth_header(client, "retry-race-user", github_id=999) db = client._db_session user = UserRepository(db).get_by_username("retry-race-user") + assert user is not None cache = GitHubLinkCache(user_id=user.id, status="dead_letter", retry_count=2) db.add(cache) diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index cdee6708..9f80ab21 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -182,33 +182,39 @@ def test_project_requires_start_date() -> None: 旧実装ではここを素通りし、repositories 層の parse_year_month("") で 500 になっていた。 """ with pytest.raises(ValidationError, match="開始年月を入力してください"): - Project( - name="API開発", - start_date="", - end_date="2024-03", - is_current=False, - technology_stacks=[], + Project.model_validate( + { + "name": "API開発", + "start_date": "", + "end_date": "2024-03", + "is_current": False, + "technology_stacks": [], + } ) def test_project_requires_end_date_when_not_current() -> None: """プロジェクト: 参画中でなければ終了年月が必須。""" with pytest.raises(ValidationError, match="終了年月"): - Project( - name="API開発", - start_date="2021-04", - end_date="", - is_current=False, - technology_stacks=[], + Project.model_validate( + { + "name": "API開発", + "start_date": "2021-04", + "end_date": "", + "is_current": False, + "technology_stacks": [], + } ) def test_project_current_without_end_date_is_accepted() -> None: """プロジェクト: 参画中(is_current=True)なら終了年月が空でも OK。""" - proj = Project( - name="API開発", - periods=[{"start_date": "2021-04", "end_date": "", "is_current": True}], - technology_stacks=[], + proj = Project.model_validate( + { + "name": "API開発", + "periods": [{"start_date": "2021-04", "end_date": "", "is_current": True}], + "technology_stacks": [], + } ) assert proj.periods[0].start_date == "2021-04" assert proj.periods[0].end_date == "" @@ -295,31 +301,37 @@ def test_experience_end_date_none_is_rejected() -> None: def test_project_end_date_before_start_date_is_rejected() -> None: """プロジェクト: 終了日が開始日より前の場合はエラーとなること。""" with pytest.raises(ValidationError, match="開始日は終了日より前"): - Project( - name="テスト", - start_date="2024-04", - end_date="2021-03", - is_current=False, - technology_stacks=[], + Project.model_validate( + { + "name": "テスト", + "start_date": "2024-04", + "end_date": "2021-03", + "is_current": False, + "technology_stacks": [], + } ) def test_project_end_date_equals_start_date_is_accepted() -> None: """プロジェクト: 終了日 = 開始日は正常に保存されること。""" - proj = Project( - name="テスト", - periods=[{"start_date": "2024-04", "end_date": "2024-04", "is_current": False}], - technology_stacks=[], + proj = Project.model_validate( + { + "name": "テスト", + "periods": [{"start_date": "2024-04", "end_date": "2024-04", "is_current": False}], + "technology_stacks": [], + } ) assert proj.periods[0].end_date == "2024-04" def test_project_end_date_after_start_date_is_accepted() -> None: """プロジェクト: 終了日 > 開始日は正常に保存されること。""" - proj = Project( - name="テスト", - periods=[{"start_date": "2021-04", "end_date": "2024-03", "is_current": False}], - technology_stacks=[], + proj = Project.model_validate( + { + "name": "テスト", + "periods": [{"start_date": "2021-04", "end_date": "2024-03", "is_current": False}], + "technology_stacks": [], + } ) assert proj.periods[0].end_date == "2024-03" @@ -327,18 +339,22 @@ def test_project_end_date_after_start_date_is_accepted() -> None: def test_project_in_progress_end_date_is_normalized_to_empty() -> None: """プロジェクト: 参画中(is_current=True)の期間は end_date が "" に正規化されること。""" # 値が入っていても is_current=True なら "" に正規化される - proj = Project( - name="テスト", - periods=[{"start_date": "2021-04", "end_date": "2024-03", "is_current": True}], - technology_stacks=[], + proj = Project.model_validate( + { + "name": "テスト", + "periods": [{"start_date": "2021-04", "end_date": "2024-03", "is_current": True}], + "technology_stacks": [], + } ) assert proj.periods[0].end_date == "" # 空文字列も当然 OK - proj_empty = Project( - name="テスト", - periods=[{"start_date": "2021-04", "end_date": "", "is_current": True}], - technology_stacks=[], + proj_empty = Project.model_validate( + { + "name": "テスト", + "periods": [{"start_date": "2021-04", "end_date": "", "is_current": True}], + "technology_stacks": [], + } ) assert proj_empty.periods[0].end_date == "" @@ -346,12 +362,14 @@ def test_project_in_progress_end_date_is_normalized_to_empty() -> None: def test_project_end_date_none_is_rejected() -> None: """プロジェクト: end_date に None を渡すと ValidationError になる。""" with pytest.raises(ValidationError): - Project( - name="テスト", - start_date="2021-04", - end_date=None, - is_current=True, - technology_stacks=[], + Project.model_validate( + { + "name": "テスト", + "start_date": "2021-04", + "end_date": None, + "is_current": True, + "technology_stacks": [], + } ) diff --git a/backend/tests/test_skill_import_scanners.py b/backend/tests/test_skill_import_scanners.py index 1a2d6f63..df6804bd 100644 --- a/backend/tests/test_skill_import_scanners.py +++ b/backend/tests/test_skill_import_scanners.py @@ -25,10 +25,15 @@ def test_source_extensions_cover_all_ecosystems(): def test_scanner_for_extension_dispatches_by_suffix(): - assert scanner_for_extension("a/b/main.py").ecosystem == "pypi" - assert scanner_for_extension("src/index.tsx").ecosystem == "npm" - assert scanner_for_extension("cmd/main.go").ecosystem == "go" - assert scanner_for_extension("src/lib.rs").ecosystem == "cargo" + for path, ecosystem in ( + ("a/b/main.py", "pypi"), + ("src/index.tsx", "npm"), + ("cmd/main.go", "go"), + ("src/lib.rs", "cargo"), + ): + scanner = scanner_for_extension(path) + assert scanner is not None + assert scanner.ecosystem == ecosystem assert scanner_for_extension("README.md") is None assert scanner_for_extension("no_extension") is None diff --git a/docs/development.md b/docs/development.md index b39c1ea8..162f2a15 100644 --- a/docs/development.md +++ b/docs/development.md @@ -120,9 +120,10 @@ make ci # lint + test + build-web ### バックエンド ```bash -make lint-backend # ruff check(app / tests / alembic_migrations) -make test-backend # pytest -q tests -make lint-fix # ruff --fix(自動修正) +make lint-backend # ruff check(app / tests / alembic_migrations) +make typecheck-backend # pyright 型チェック(make lint に含まれる) +make test-backend # pytest -q tests +make lint-fix # ruff --fix(自動修正) ``` 特定ファイルだけ ruff したい場合: @@ -131,6 +132,8 @@ make lint-fix # ruff --fix(自動修正) nix develop --command bash -c "cd backend && .venv/bin/python -m ruff check " ``` +> **pyright の前提**: 型チェックは `backend/pyproject.toml` の `[tool.pyright]`(`venv=".venv"`)を参照するため、事前に `make install-backend` で `backend/.venv` へ依存を入れておくこと。バージョンは CI(`.github/workflows/test.yml`)と Makefile でピン留めを揃えている。 + ### フロントエンド(ユニット・ビルド) ```bash