From dc00a4f8f58c2490eda7facd4d6aa5217ce10ca5 Mon Sep 17 00:00:00 2001 From: CIAgent Date: Sun, 27 Sep 2026 10:08:07 +0000 Subject: [PATCH] =?UTF-8?q?chore(P05):=20type=20&=20dependency=20hardening?= =?UTF-8?q?=20=E2=80=94=20mypy=20+=20recharts=203=20(REQ-7-005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy] check_untyped_defs + no_implicit_optional + warn_unused_ignores + warn_redundant_casts + warn_unreachable, no overrides (every dep ships py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found by the gate (LLMProvider protocol async-generator shape, SessionStore protocol sync/async mismatch, structured_reply genericity, get_args migrations, sandbox-agent Optional guard honesty, proctor test arg-order, dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead removed, 15 scoped (warn_unused_ignores self-eliminates the rest). Wired: scripts/typecheck.sh + package.json + turbo task + root ai:typecheck — pnpm typecheck now 5 tsc + mypy. Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class (Tooltip formatter ValueType typing); 2 one-line inference fixes on the 2 chart files; no rollback needed (well inside the G-106 timebox). Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248 with chart SSR markers verified in exported employer + dashboard pages. Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain, all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight + rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via c8→test-exclude (D-052-binding coverage tool — swapping it is out of budget). Python: no EOL packages; zero-warning regime under filterwarnings=error is the machine-checked proof. Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still exactly 2. ---ci--- phase: 5 milestone: v0.7 status: execute ---/ci--- --- .ciagent/CHECKPOINT.json | 6 +- apps/ai-service/ai_service/agents/base.py | 14 +- apps/ai-service/ai_service/agents/session.py | 14 +- apps/ai-service/ai_service/api/assessment.py | 7 +- apps/ai-service/ai_service/api/lab.py | 6 +- apps/ai-service/ai_service/api/marketplace.py | 6 +- .../ai_service/api/marketplace_admin.py | 2 + apps/ai-service/ai_service/api/proctor.py | 8 +- apps/ai-service/ai_service/api/sandboxes.py | 8 +- apps/ai-service/ai_service/grading/store.py | 6 +- apps/ai-service/ai_service/identity/mock.py | 2 + apps/ai-service/ai_service/identity/store.py | 17 +- apps/ai-service/ai_service/llm/base.py | 6 +- apps/ai-service/ai_service/main.py | 8 +- .../ai_service/marketplace/store.py | 39 +- apps/ai-service/ai_service/sandbox/manager.py | 10 +- .../ai-service/ai_service/telemetry/models.py | 6 +- apps/ai-service/ai_service/telemetry/store.py | 10 +- apps/ai-service/ai_service/variants/store.py | 8 +- .../ai_service/voice/defense_store.py | 16 +- apps/ai-service/ai_service/voice/factory.py | 9 +- .../ai_service/voice/openai_audio.py | 6 +- apps/ai-service/package.json | 3 +- apps/ai-service/pyproject.toml | 39 +- apps/ai-service/scripts/sandbox-agent.py | 4 + apps/ai-service/scripts/typecheck.sh | 19 + .../tests/agents/test_corpus_inputs.py | 12 +- apps/ai-service/tests/agents/test_lab.py | 9 +- apps/ai-service/tests/agents/test_proctor.py | 16 +- apps/ai-service/tests/agents/test_session.py | 4 +- .../tests/agents/test_structured.py | 5 +- apps/ai-service/tests/api/test_assessment.py | 7 +- apps/ai-service/tests/api/test_defense.py | 3 +- apps/ai-service/tests/api/test_lab.py | 5 +- apps/ai-service/tests/api/test_proctor.py | 13 +- .../tests/api/test_telemetry_ingest.py | 4 +- apps/ai-service/tests/api/test_variants.py | 4 +- apps/ai-service/tests/conftest.py | 10 +- .../tests/grading/test_calibration.py | 5 +- apps/ai-service/tests/grading/test_engine.py | 5 +- apps/ai-service/tests/grading/test_store.py | 3 +- .../tests/identity/test_identity.py | 5 +- apps/ai-service/tests/llm/test_mock.py | 4 +- apps/ai-service/tests/marketplace/conftest.py | 15 +- .../tests/marketplace/test_embedding.py | 9 +- .../tests/marketplace/test_ingest.py | 5 +- .../tests/marketplace/test_marketplace_api.py | 13 +- .../tests/marketplace/test_resume.py | 5 +- .../tests/marketplace/test_search.py | 9 +- .../tests/marketplace/test_sitemap.py | 7 +- .../tests/marketplace/test_store.py | 7 +- .../tests/marketplace/test_tagging.py | 5 +- apps/ai-service/tests/sandbox/conftest.py | 3 +- .../tests/sandbox/test_sandbox_agent.py | 8 +- .../tests/sandbox/test_telemetry_wiring.py | 4 +- .../tests/telemetry/test_durability.py | 25 +- .../ai-service/tests/telemetry/test_models.py | 4 +- .../tests/variants/test_environments.py | 16 +- .../tests/variants/test_generator.py | 9 +- apps/ai-service/tests/variants/test_store.py | 3 +- .../tests/variants/test_templates.py | 5 +- .../tests/voice/test_defense_store.py | 5 +- apps/ai-service/tests/voice/test_latency.py | 3 +- .../tests/voice/test_openai_audio.py | 5 +- .../components/employer/analytics-charts.tsx | 2 +- .../web/components/learner/progress-graph.tsx | 2 +- apps/web/package.json | 3 +- package.json | 1 + pnpm-lock.yaml | 441 +++++------------- turbo.json | 4 + 70 files changed, 513 insertions(+), 498 deletions(-) create mode 100755 apps/ai-service/scripts/typecheck.sh diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 2007a17..bb5ffdf 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,9 +1,9 @@ { - "phase": 4, - "stage": "complete", + "phase": 5, + "stage": "verify", "milestone": "v0.7", "milestone_name": "test-harness-review", "phase_role": "execution", "attempts": 0, - "updated_at": "2026-09-27T05:30:00Z" + "updated_at": "2026-09-27T06:20:00Z" } \ No newline at end of file diff --git a/apps/ai-service/ai_service/agents/base.py b/apps/ai-service/ai_service/agents/base.py index 1fe7122..e681d33 100644 --- a/apps/ai-service/ai_service/agents/base.py +++ b/apps/ai-service/ai_service/agents/base.py @@ -6,6 +6,7 @@ The default pipeline: build_messages() → provider.stream_chat()/chat(). from abc import ABC, abstractmethod from collections.abc import AsyncIterator +from typing import TypeVar from pydantic import BaseModel @@ -15,6 +16,8 @@ from ..llm.base import LLMProvider from ..llm.types import Message from .structured import structured_completion +T = TypeVar("T", bound=BaseModel) + class BaseAgent(ABC): """A tutor agent: system prompt + message assembly + provider delegation.""" @@ -64,10 +67,15 @@ class BaseAgent(ABC): history: list[Message] | None = None, user_input: str = "", learner_context: LearnerContext | None = None, - schema: type[BaseModel] | None = None, + schema: type[T] | None = None, schema_hint: str = "", - ) -> BaseModel: - """Non-streaming completion parsed into a pydantic model (D-020 defense).""" + ) -> T: + """Non-streaming completion parsed into a pydantic model (D-020 defense). + + P5: generic over `schema` — the returned model is the schema's own + type, so callers (assessor/proctor) get concrete types, no casts. + `schema=None` raises before T is ever produced. + """ if schema is None: raise ValueError("structured_reply requires a schema") messages = self.build_messages(history, user_input, learner_context) diff --git a/apps/ai-service/ai_service/agents/session.py b/apps/ai-service/ai_service/agents/session.py index 2cece02..0c1ca61 100644 --- a/apps/ai-service/ai_service/agents/session.py +++ b/apps/ai-service/ai_service/agents/session.py @@ -28,15 +28,19 @@ class AgentSession: class SessionStore(Protocol): - def get(self, session_id: str) -> AgentSession | None: ... - def create( + # P5 (D-058): the protocol was previously declared sync while the + # shipped implementation is async — every API call site awaited a + # non-awaitable per the old signature. The protocol now tells the + # truth: all five methods are async (InMemorySessionStore's shape). + async def get(self, session_id: str) -> AgentSession | None: ... + async def create( self, session_id: str, agent: str, learner_id: str = "seed-learner-1" ) -> AgentSession: ... - def append(self, session_id: str, message: Message) -> None: ... - def history_window( + async def append(self, session_id: str, message: Message) -> None: ... + async def history_window( self, session_id: str, max_messages: int = DEFAULT_WINDOW ) -> list[Message]: ... - def delete(self, session_id: str) -> None: ... + async def delete(self, session_id: str) -> None: ... class InMemorySessionStore: diff --git a/apps/ai-service/ai_service/api/assessment.py b/apps/ai-service/ai_service/api/assessment.py index 365a021..c4c2e5e 100644 --- a/apps/ai-service/ai_service/api/assessment.py +++ b/apps/ai-service/ai_service/api/assessment.py @@ -54,11 +54,12 @@ detail instead of rubric scores), so consumers read ONE shape. """ from datetime import datetime -from typing import Any +from typing import Any, cast from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from ..agents.assessor import AssessorAgent from ..agents.registry import AgentRegistry from ..agents.structured import StructuredOutputError from ..config import Settings @@ -103,7 +104,9 @@ async def assessment_evaluate( status_code=404, detail=f"no stored grade for {body.learner_id}/{body.task_id} - grade first", ) - agent = registry.get(provider, settings, "assessor") + # P5: registry.get returns BaseAgent; "assessor" is registered as + # AssessorAgent (register_builtin_agents) — the cast states that mapping. + agent = cast(AssessorAgent, registry.get(provider, settings, "assessor")) learner_context = get_learner_context(body.learner_id) try: coaching = await agent.coach_grade(grade, learner_context) diff --git a/apps/ai-service/ai_service/api/lab.py b/apps/ai-service/ai_service/api/lab.py index 5b97d14..4a1ff55 100644 --- a/apps/ai-service/ai_service/api/lab.py +++ b/apps/ai-service/ai_service/api/lab.py @@ -9,11 +9,13 @@ D-016 envelope with agent=lab. import json from collections.abc import AsyncIterator +from typing import cast from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse +from ..agents.lab import LabAgent from ..agents.registry import AgentRegistry from ..config import Settings from ..corpus.learner_context import get_learner_context @@ -41,7 +43,9 @@ async def lab_feedback( provider=Depends(get_provider), trace_store=Depends(get_trace_store), ) -> EventSourceResponse: - agent = registry.get(provider, settings, "lab") + # P5: registry.get returns BaseAgent; "lab" is registered as LabAgent + # (register_builtin_agents) — the cast states that mapping. + agent = cast(LabAgent, registry.get(provider, settings, "lab")) learner_context = get_learner_context(body.learner_id) trace = ( trace_store.get_trace(body.learner_id, body.task_id) diff --git a/apps/ai-service/ai_service/api/marketplace.py b/apps/ai-service/ai_service/api/marketplace.py index f468589..ad6e9fe 100644 --- a/apps/ai-service/ai_service/api/marketplace.py +++ b/apps/ai-service/ai_service/api/marketplace.py @@ -283,7 +283,11 @@ async def search_postings( SearchEntry( posting=_posting_response(r.posting), match_score=round(r.score, 6) if has_query else None, - skill_breakdown=r.skill_breakdown if has_query else [], + # P5 scoped: search.skill_breakdown() yields raw dicts in the + # exact entry shape (marketplace/ may not import api/ models — + # D-048 boundary); pydantic coerces them into + # SkillBreakdownEntry at this SearchEntry boundary. + skill_breakdown=r.skill_breakdown if has_query else [], # type: ignore[arg-type] ) for r in ranked ], diff --git a/apps/ai-service/ai_service/api/marketplace_admin.py b/apps/ai-service/ai_service/api/marketplace_admin.py index 30e1bf4..6a7438e 100644 --- a/apps/ai-service/ai_service/api/marketplace_admin.py +++ b/apps/ai-service/ai_service/api/marketplace_admin.py @@ -171,6 +171,8 @@ async def create_posting( description=body.description, required_competencies=body.required_competencies, skills=body.skills, + # P5: admin body carries str; Posting's Literal contract — the + # store's validates hook rejects out-of-taxonomy values. seniority=body.seniority, # type: ignore[arg-type] category=body.category, location=body.location, diff --git a/apps/ai-service/ai_service/api/proctor.py b/apps/ai-service/ai_service/api/proctor.py index f4e045b..f97dd75 100644 --- a/apps/ai-service/ai_service/api/proctor.py +++ b/apps/ai-service/ai_service/api/proctor.py @@ -5,10 +5,12 @@ variant seed cross-check, no corpus scenarios. The proctor coaches: a pydantic-validated ProctorAssessment (JSON response, not SSE). """ +from typing import cast + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from ..agents.proctor import ProctorAssessment +from ..agents.proctor import ProctorAgent, ProctorAssessment from ..agents.registry import AgentRegistry from ..config import Settings from ..corpus.learner_context import get_learner_context @@ -41,7 +43,9 @@ async def proctor_signals( voice_store=Depends(get_voice_store), ) -> ProctorAssessment: """Real integrity inputs: live digest + defense signals + variant context.""" - agent = registry.get(provider, settings, "proctor") + # P5: registry.get returns BaseAgent; "proctor" is registered as + # ProctorAgent (register_builtin_agents) — the cast states that mapping. + agent = cast(ProctorAgent, registry.get(provider, settings, "proctor")) learner_context = get_learner_context(body.learner_id) trace = ( diff --git a/apps/ai-service/ai_service/api/sandboxes.py b/apps/ai-service/ai_service/api/sandboxes.py index 53a4af9..ba96af8 100644 --- a/apps/ai-service/ai_service/api/sandboxes.py +++ b/apps/ai-service/ai_service/api/sandboxes.py @@ -23,6 +23,7 @@ never part of the API contract. import time from collections import deque +from collections.abc import Set as AbstractSet from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -357,7 +358,9 @@ _GENERIC_FIRST_TOKENS = frozenset( def _enforce_exec_policy( - cmd: list[str], environment: str | None, allowed: set[str] | None = None + # P5: AbstractSet[str] — the caller unions frozenset + set; membership + # is all this function does with it. + cmd: list[str], environment: str | None, allowed: AbstractSet[str] | None = None ) -> None: """422 with the allowed set when a design/sim command is out of policy. @@ -366,7 +369,8 @@ def _enforce_exec_policy( template must not reject its own Run command).""" if environment not in ("design", "simulation"): return # build kind: unchanged v0.3 semantics - allowed = set(allowed) if allowed is not None else set(_GENERIC_FIRST_TOKENS) + if allowed is None: + allowed = _GENERIC_FIRST_TOKENS first = cmd[0] if cmd else "" if first in ("sh", "bash"): raise HTTPException( diff --git a/apps/ai-service/ai_service/grading/store.py b/apps/ai-service/ai_service/grading/store.py index d65286f..26cdd1d 100644 --- a/apps/ai-service/ai_service/grading/store.py +++ b/apps/ai-service/ai_service/grading/store.py @@ -37,7 +37,7 @@ from typing import Any, Protocol import sqlalchemy as sa from sqlalchemy import JSON, Index from sqlalchemy.orm import validates -from sqlmodel import Field, Session, SQLModel, create_engine, select +from sqlmodel import Field, Session, SQLModel, col, create_engine, select from ..config import Settings @@ -225,7 +225,9 @@ class SQLiteGradeStore: .where(GradeRecord.learner_id == learner_id) # Chronological; task_id is a deterministic tie-break for # grades stamped within the same instant. - .order_by(GradeRecord.created_at, GradeRecord.task_id) + # P5: col() — SQLModel class attrs are Mapped[T], the + # column reference rides col() in order_by. + .order_by(col(GradeRecord.created_at), GradeRecord.task_id) ) results = session.exec(stmt).all() for row in results: diff --git a/apps/ai-service/ai_service/identity/mock.py b/apps/ai-service/ai_service/identity/mock.py index 20218be..0785ae9 100644 --- a/apps/ai-service/ai_service/identity/mock.py +++ b/apps/ai-service/ai_service/identity/mock.py @@ -87,6 +87,8 @@ class MockIdentityProvider: ) return IdentityVerdict( status="verified", + # P5: derive_age_band returns str; the verdict's Literal contract — + # the derived bands are in-contract by construction. age_band=band, # type: ignore[arg-type] provider="mock", mock=True, diff --git a/apps/ai-service/ai_service/identity/store.py b/apps/ai-service/ai_service/identity/store.py index 98df4bb..bbf6c77 100644 --- a/apps/ai-service/ai_service/identity/store.py +++ b/apps/ai-service/ai_service/identity/store.py @@ -22,13 +22,14 @@ module imports config only. from __future__ import annotations import logging +import sqlite3 from datetime import UTC, datetime from pathlib import Path from typing import Any, Protocol from sqlalchemy import event, func, text from sqlalchemy.types import JSON, String -from sqlmodel import Field, Session, SQLModel, create_engine, select +from sqlmodel import Field, Session, SQLModel, col, create_engine, select from ..config import Settings from .base import IdentityStatus @@ -91,14 +92,16 @@ def _as_utc(ts: datetime) -> datetime: return ts -def _sqlite_connect(dbapi_connection: object, _: object) -> None: - """Per-connection pragmas — mirrors the other D-027 stores.""" - cursor = dbapi_connection.cursor() # type: ignore[attr-defined] +def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None: + """Per-connection pragmas — mirrors the other D-027 stores (P5: the + concrete sqlite3.Connection type removes the two attr-defined ignores + the loose `object` annotation needed).""" + cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA busy_timeout=5000") cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() # type: ignore[attr-defined] + cursor.close() class IdentityStore(Protocol): @@ -172,7 +175,9 @@ class SQLiteIdentityStore: select(IdentityRecord) .where(IdentityRecord.learner_id == learner_id) .order_by( - IdentityRecord.submitted_at.desc(), + # P5: col() — SQLModel class attrs are Mapped[T], the + # column operators (.desc()) ride col(). + col(IdentityRecord.submitted_at).desc(), text("rowid DESC"), ) ).first() diff --git a/apps/ai-service/ai_service/llm/base.py b/apps/ai-service/ai_service/llm/base.py index 641345a..90c8bfe 100644 --- a/apps/ai-service/ai_service/llm/base.py +++ b/apps/ai-service/ai_service/llm/base.py @@ -12,7 +12,11 @@ from .types import Message class LLMProvider(Protocol): - async def stream_chat( + # P5 (D-058): plain `def` returning AsyncIterator — the honest shape for + # async-generator implementations. `async def -> AsyncIterator` makes mypy + # see a coroutine WRAPPING the iterator (every call site would need an + # extra await); async generators ARE functions returning AsyncIterator. + def stream_chat( self, messages: list[Message], *, diff --git a/apps/ai-service/ai_service/main.py b/apps/ai-service/ai_service/main.py index b392178..e679514 100644 --- a/apps/ai-service/ai_service/main.py +++ b/apps/ai-service/ai_service/main.py @@ -326,7 +326,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse - def _scrub_pii_validation(request, exc): # type: ignore[unused-arg] + def _scrub_pii_validation(request, exc): + # P5: the old `# type: ignore[unused-arg]` was inert (the unused-arg + # code is not enabled by this baseline); removed per D-058. pii_fields = frozenset({"date_of_birth"}) scrubbed = [] for err in exc.errors(): @@ -384,7 +386,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: ) @app.exception_handler(404) - async def _spa_404(request, exc): # type: ignore[unused] + async def _spa_404(request, exc): + # P5: the old `# type: ignore[unused]` was inert (the unused code + # is not enabled by this baseline); removed per D-058. not_found_page = web_dir / "404.html" if not_found_page.is_file(): return FileResponse(not_found_page, status_code=404) diff --git a/apps/ai-service/ai_service/marketplace/store.py b/apps/ai-service/ai_service/marketplace/store.py index 2eef699..e4cc79c 100644 --- a/apps/ai-service/ai_service/marketplace/store.py +++ b/apps/ai-service/ai_service/marketplace/store.py @@ -21,6 +21,7 @@ this module imports config + base only. from __future__ import annotations +import sqlite3 import struct import uuid from datetime import UTC, datetime @@ -29,7 +30,7 @@ from typing import Any, Protocol from sqlalchemy import UniqueConstraint, event, func, text from sqlalchemy.types import JSON, String -from sqlmodel import Field, Session, SQLModel, create_engine, select +from sqlmodel import Field, Session, SQLModel, col, create_engine, select from ..config import Settings from .base import ( @@ -122,6 +123,7 @@ class PostingRow(SQLModel, table=True): description=self.description, required_competencies=list(self.required_competencies or []), skills=list(self.skills or []), + # P5: row stores str; Posting's Literal[...] contract — validated at write. seniority=self.seniority, # type: ignore[arg-type] category=self.category, location=self.location, @@ -129,6 +131,7 @@ class PostingRow(SQLModel, table=True): salary_min=self.salary_min, salary_max=self.salary_max, status=self.status, # type: ignore[arg-type] + # P5: tags is the raw JSON dict on the row; Posting wants the model. tags=self.tags, # type: ignore[arg-type] posted_at=_as_utc(self.posted_at) if self.posted_at else None, ) @@ -271,14 +274,16 @@ def _decode_vector(blob: bytes, model: str, dim: int) -> list[float]: return list(struct.unpack(f"<{dim}f", bytes(blob))) -def _sqlite_connect(dbapi_connection: object, _: object) -> None: - """Per-connection pragmas — mirrors the other D-027 stores.""" - cursor = dbapi_connection.cursor() # type: ignore[attr-defined] +def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None: + """Per-connection pragmas — mirrors the other D-027 stores (P5: the + concrete sqlite3.Connection type removes the two attr-defined ignores + the loose `object` annotation needed).""" + cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA busy_timeout=5000") cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() # type: ignore[attr-defined] + cursor.close() class InvalidTransition(ValueError): @@ -571,7 +576,9 @@ class SQLiteMarketplaceStore: session.exec( select(PostingRow) .where(PostingRow.status == "published") - .order_by(PostingRow.posted_at.desc(), PostingRow.id) + # P5: col() — SQLModel class attrs are Mapped[T], the + # column operators (.desc()/.isnot()/.in_()) ride col(). + .order_by(col(PostingRow.posted_at).desc(), PostingRow.id) .offset(offset) .limit(limit) ) @@ -760,7 +767,7 @@ class SQLiteMarketplaceStore: params, ).fetchall() ] - stmt = stmt.where(PostingRow.id.in_(skill_ids or ["__none__"])) + stmt = stmt.where(col(PostingRow.id).in_(skill_ids or ["__none__"])) if seniority is not None: stmt = stmt.where(PostingRow.seniority == seniority) if category is not None: @@ -773,16 +780,16 @@ class SQLiteMarketplaceStore: # a posting qualifies when its advertised band overlaps # the requested band: posting.salary_max >= salary_min. stmt = stmt.where( - PostingRow.salary_max.isnot(None), - PostingRow.salary_max >= salary_min, + col(PostingRow.salary_max).isnot(None), + col(PostingRow.salary_max) >= salary_min, ) if salary_max is not None: stmt = stmt.where( - PostingRow.salary_min.isnot(None), - PostingRow.salary_min <= salary_max, + col(PostingRow.salary_min).isnot(None), + col(PostingRow.salary_min) <= salary_max, ) rows = session.exec( - stmt.order_by(PostingRow.posted_at.desc(), PostingRow.id) + stmt.order_by(col(PostingRow.posted_at).desc(), PostingRow.id) ).all() return [r.to_posting() for r in rows] @@ -880,7 +887,9 @@ class SQLiteMarketplaceStore: stmt = select(ApplicationRow) if posting_id is not None: stmt = stmt.where(ApplicationRow.posting_id == posting_id) - rows = session.exec(stmt.order_by(ApplicationRow.created_at.desc())).all() + rows = session.exec( + stmt.order_by(col(ApplicationRow.created_at).desc()) + ).all() return [r.to_application() for r in rows] # -- P4-A: enrichment (append-only, D-053) -------------------------------- @@ -915,7 +924,7 @@ class SQLiteMarketplaceStore: rows = session.exec( select(EmployerEnrichmentRow) .where(EmployerEnrichmentRow.employer_id == employer_id) - .order_by(EmployerEnrichmentRow.created_at.desc(), EmployerEnrichmentRow.id) + .order_by(col(EmployerEnrichmentRow.created_at).desc(), EmployerEnrichmentRow.id) ).all() return [ { @@ -949,7 +958,7 @@ class SQLiteMarketplaceStore: PostingRow.employer_id == employer_id, PostingRow.status == "published", ) - .order_by(PostingRow.posted_at.desc()) + .order_by(col(PostingRow.posted_at).desc()) ).all() return [r.title for r in rows] diff --git a/apps/ai-service/ai_service/sandbox/manager.py b/apps/ai-service/ai_service/sandbox/manager.py index 39d8225..d38893a 100644 --- a/apps/ai-service/ai_service/sandbox/manager.py +++ b/apps/ai-service/ai_service/sandbox/manager.py @@ -38,6 +38,7 @@ BOUNDARY: this module NEVER imports `api/` or `agents/`. from __future__ import annotations import asyncio +import builtins import json import logging import os @@ -281,7 +282,7 @@ class SandboxManager: # -- reapers ------------------------------------------------------------ - async def reap_expired(self) -> list[str]: + async def reap_expired(self) -> builtins.list[str]: """One reaper pass: wall-clock timeout + G-2 workdir-size sweep. Destroys sandboxes older than `settings.sandbox_timeout_s`, then sweeps @@ -290,9 +291,14 @@ class SandboxManager: signal). Returns the ids destroyed this pass. Invoke on an async timer (the app lifespan owns the loop interval); both checks deliberately share one pass so the periodic work is O(live sandboxes) once. + + P5: the return annotation is `builtins.list` because this class's + own `list()` method shadows the `list[...]` subscript form in + annotations below it (a mypy scoping quirk — the method name is + public API and stays). """ now = self._clock() - destroyed: list[str] = [] + destroyed: builtins.list[str] = [] timeout_s = float(self._settings.sandbox_timeout_s) cap_bytes = int(self._settings.sandbox_max_workdir_mb) * 1024 * 1024 diff --git a/apps/ai-service/ai_service/telemetry/models.py b/apps/ai-service/ai_service/telemetry/models.py index 5337aa5..f05b5de 100644 --- a/apps/ai-service/ai_service/telemetry/models.py +++ b/apps/ai-service/ai_service/telemetry/models.py @@ -21,7 +21,7 @@ Boundary (D-027): telemetry/ never imports agents/ or api/. """ from datetime import datetime -from typing import Any, Literal +from typing import Any, Literal, get_args from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import JSON, Index, String @@ -38,7 +38,9 @@ EventKind = Literal[ "stdin", "stdout", ] -_EVENT_KINDS: frozenset[str] = frozenset(EventKind.__args__) +# P5: get_args() is the typed accessor for Literal values (bare __args__ +# trips mypy on the typing special form). +_EVENT_KINDS: frozenset[str] = frozenset(get_args(EventKind)) class TelemetryEvent(SQLModel, table=True): diff --git a/apps/ai-service/ai_service/telemetry/store.py b/apps/ai-service/ai_service/telemetry/store.py index 9208110..403ce40 100644 --- a/apps/ai-service/ai_service/telemetry/store.py +++ b/apps/ai-service/ai_service/telemetry/store.py @@ -28,7 +28,7 @@ from pathlib import Path from typing import Any, Protocol import sqlalchemy as sa -from sqlmodel import Session, SQLModel, create_engine, select +from sqlmodel import Session, SQLModel, col, create_engine, select from ..config import Settings from .models import TelemetryEvent @@ -151,7 +151,9 @@ class SQLiteTraceStore: select(TelemetryEvent) .where(TelemetryEvent.learner_id == learner_id) .where(TelemetryEvent.task_id == task_id) - .order_by(TelemetryEvent.seq) + # P5: col() — SQLModel class attrs are Mapped[T], the + # column reference rides col() in order_by/func.count. + .order_by(col(TelemetryEvent.seq)) ) results = session.exec(stmt).all() # Detach from the session: callers must not depend on open-session @@ -167,7 +169,7 @@ class SQLiteTraceStore: select(TelemetryEvent.seq) .where(TelemetryEvent.learner_id == learner_id) .where(TelemetryEvent.task_id == task_id) - .order_by(TelemetryEvent.seq) + .order_by(col(TelemetryEvent.seq)) ) # sqlmodel scalar select: rows are plain ints, not 1-tuples. return [int(seq) for seq in session.exec(stmt).all()] @@ -196,7 +198,7 @@ class SQLiteTraceStore: # docstring-free body keeps it obvious what the query shape is. with self._session() as session: stmt = ( - select(sa.func.count(TelemetryEvent.seq)) + select(sa.func.count(col(TelemetryEvent.seq))) .where(TelemetryEvent.learner_id == learner_id) .where(TelemetryEvent.task_id == task_id) ) diff --git a/apps/ai-service/ai_service/variants/store.py b/apps/ai-service/ai_service/variants/store.py index 5ada362..d09793a 100644 --- a/apps/ai-service/ai_service/variants/store.py +++ b/apps/ai-service/ai_service/variants/store.py @@ -43,7 +43,7 @@ from typing import Any, Protocol import sqlalchemy as sa from sqlalchemy import JSON, Index, String, UniqueConstraint from sqlalchemy.orm import validates -from sqlmodel import Field, Session, SQLModel, create_engine, select +from sqlmodel import Field, Session, SQLModel, col, create_engine, select from ..config import Settings @@ -325,7 +325,9 @@ class SQLiteVariantStore: .where(VariantRecord.learner_id == learner_id) # Chronological; task_id is a deterministic tie-break for # variants stamped within the same instant. - .order_by(VariantRecord.created_at, VariantRecord.task_id) + # P5: col() — SQLModel class attrs are Mapped[T], the + # column reference rides col() in order_by. + .order_by(col(VariantRecord.created_at), VariantRecord.task_id) ) results = session.exec(stmt).all() for row in results: @@ -339,7 +341,7 @@ class SQLiteVariantStore: select(VariantRecord) .where(VariantRecord.template_id == template_id) # Chronological; learner_id is a deterministic tie-break. - .order_by(VariantRecord.created_at, VariantRecord.learner_id) + .order_by(col(VariantRecord.created_at), VariantRecord.learner_id) ) results = session.exec(stmt).all() for row in results: diff --git a/apps/ai-service/ai_service/voice/defense_store.py b/apps/ai-service/ai_service/voice/defense_store.py index 3fe5df7..cc19004 100644 --- a/apps/ai-service/ai_service/voice/defense_store.py +++ b/apps/ai-service/ai_service/voice/defense_store.py @@ -63,22 +63,24 @@ from collections.abc import Iterator from contextlib import contextmanager from datetime import UTC, datetime from pathlib import Path -from typing import Any, Literal, Protocol +from typing import Any, Literal, Protocol, get_args import sqlalchemy as sa from sqlalchemy import JSON, Index, String from sqlalchemy.orm import validates -from sqlmodel import Field, Session, SQLModel, create_engine, select +from sqlmodel import Field, Session, SQLModel, col, create_engine, select from ..config import Settings logger = logging.getLogger(__name__) DefenseStatus = Literal["in_progress", "finished"] -_DEFENSE_STATUSES: frozenset[str] = frozenset(DefenseStatus.__args__) +# P5: get_args() is the typed accessor for Literal values (bare __args__ +# trips mypy on the typing special form). +_DEFENSE_STATUSES: frozenset[str] = frozenset(get_args(DefenseStatus)) TurnRole = Literal["examiner", "learner"] -_TURN_ROLES: frozenset[str] = frozenset(TurnRole.__args__) +_TURN_ROLES: frozenset[str] = frozenset(get_args(TurnRole)) class DefenseRecord(SQLModel, table=True): @@ -464,7 +466,9 @@ class SQLiteDefenseStore: stmt = ( select(DefenseTurn) .where(DefenseTurn.defense_id == defense_id) - .order_by(DefenseTurn.seq) + # P5: col() — SQLModel class attrs are Mapped[T], the + # column reference rides col() in order_by. + .order_by(col(DefenseTurn.seq)) ) turns = session.exec(stmt).all() for turn in turns: @@ -489,7 +493,7 @@ class SQLiteDefenseStore: .where(DefenseRecord.learner_id == learner_id) # Chronological; id is a deterministic tie-break for # defenses stamped within the same instant. - .order_by(DefenseRecord.created_at, DefenseRecord.id) + .order_by(col(DefenseRecord.created_at), DefenseRecord.id) ) results = session.exec(stmt).all() for row in results: diff --git a/apps/ai-service/ai_service/voice/factory.py b/apps/ai-service/ai_service/voice/factory.py index a1ca19f..d39efe4 100644 --- a/apps/ai-service/ai_service/voice/factory.py +++ b/apps/ai-service/ai_service/voice/factory.py @@ -11,12 +11,14 @@ descriptor then honestly reports mode='mock' so the UI badge cannot lie. from __future__ import annotations +from typing import cast + import httpx from ..config import Settings from .base import VoiceProvider from .mock import MockVoiceProvider -from .openai_audio import OpenAIAudioProvider +from .openai_audio import OpenAIAudioProvider, TtsFormat class UnknownVoiceProviderError(ValueError): @@ -60,7 +62,10 @@ def voice_provider_from_settings( stt_model=settings.voice_stt_model, tts_model=settings.voice_tts_model, tts_voice=settings.voice_tts_voice, - tts_format=settings.voice_tts_format, + # P5: Settings.voice_tts_format is str, but its pydantic + # validator normalizes to exactly mp3|wav|opus (invalid → mp3 + # with a warning) — the cast states that provenance. + tts_format=cast(TtsFormat, settings.voice_tts_format), ) raise UnknownVoiceProviderError( f"unknown AI_VOICE_PROVIDER {name!r}: use 'mock', 'browser', or 'openai-audio'" diff --git a/apps/ai-service/ai_service/voice/openai_audio.py b/apps/ai-service/ai_service/voice/openai_audio.py index 7822728..b30770a 100644 --- a/apps/ai-service/ai_service/voice/openai_audio.py +++ b/apps/ai-service/ai_service/voice/openai_audio.py @@ -19,6 +19,10 @@ import httpx from .base import TranscriptSegment, VoiceDescriptor +#: TTS response formats the OpenAI audio endpoint accepts (P5: named so +#: the factory can type-honestly pass the config-normalized value through). +TtsFormat = Literal["mp3", "wav", "opus"] + class OpenAIAudioProvider: """Server STT (`/audio/transcriptions`) + TTS (`/audio/speech`).""" @@ -31,7 +35,7 @@ class OpenAIAudioProvider: stt_model: str = "whisper-1", tts_model: str = "tts-1", tts_voice: str = "alloy", - tts_format: Literal["mp3", "wav", "opus"] = "mp3", + tts_format: TtsFormat = "mp3", ) -> None: self._client = http_client self._base_url = base_url.rstrip("/") diff --git a/apps/ai-service/package.json b/apps/ai-service/package.json index 65cc7ab..6d1c40b 100644 --- a/apps/ai-service/package.json +++ b/apps/ai-service/package.json @@ -6,6 +6,7 @@ "dev": "bash scripts/dev.sh", "test": "bash scripts/test.sh", "bootstrap": "bash scripts/bootstrap.sh", - "lint": "bash scripts/lint.sh" + "lint": "bash scripts/lint.sh", + "typecheck": "bash scripts/typecheck.sh" } } \ No newline at end of file diff --git a/apps/ai-service/pyproject.toml b/apps/ai-service/pyproject.toml index 1f7002f..5907c46 100644 --- a/apps/ai-service/pyproject.toml +++ b/apps/ai-service/pyproject.toml @@ -33,6 +33,8 @@ dev = [ "pytest-cov>=7.1,<8", "pytest-timeout>=2.4,<3", "ruff>=0.14", + # P5 type hardening (D-058): mypy relaxed-first baseline. Resolved 2.3.1. + "mypy>=2.3,<2.4", ] [tool.setuptools.packages.find] @@ -106,4 +108,39 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "RUF100"] # B008: Depends() in argument defaults is the idiomatic FastAPI DI pattern -ignore = ["B008"] \ No newline at end of file +ignore = ["B008"] + +# P5 mypy relaxed-first baseline (D-058) — NOT full strict. The flags +# below are the binding set from the decision; per-module tightening via +# [[tool.mypy.overrides]] is available later (the plan's Wave-5 follow-up +# form). Scope decision (recorded per the task's pragmatic clause): ALL +# THREE trees pass — ai_service/ + scripts/sandbox-agent.py + tests/ — +# so tests are NOT excluded; every test violation was either fixed at +# source or scoped with a coded ignore + reason inline. Tests remain +# fully checked; a future tests.* per-module relaxation override stays +# available (D-058) if the annotation burden grows. +[tool.mypy] +python_version = "3.11" +files = [ + "ai_service", + "scripts/sandbox-agent.py", + "tests", +] +# Untracked runtime droppings under tests/ (gitignored via +# `apps/ai-service/**/sandboxes/`) must never be type-checked. +exclude = ["tests/sandboxes"] +# D-058 relaxed-first baseline: +check_untyped_defs = true # real bugs hide in unannotated bodies too +no_implicit_optional = true # implicit Optional is a genuine bug class +warn_unused_ignores = true # stale `# type: ignore` becomes an error (self-eliminating suppressions) +warn_redundant_casts = true +warn_unreachable = true +warn_unused_configs = true +# Start strict on imports: every runtime dep checked code imports ships +# stubs (verified py.typed: fastapi/uvicorn/pydantic/pydantic-settings/ +# httpx/sse-starlette/sqlmodel/sqlalchemy/websockets/python-multipart/ +# pypdf/pytest/pytest-asyncio; aiofiles + pytest-timeout/pytest-cov are +# never imported by checked code). Overrides are reserved for deps that +# genuinely lack stubs — currently NONE, so no ignore_missing_imports +# overrides are listed; warn_unused_configs would flag any added no-op. +ignore_missing_imports = false \ No newline at end of file diff --git a/apps/ai-service/scripts/sandbox-agent.py b/apps/ai-service/scripts/sandbox-agent.py index 51c8480..4f85a95 100644 --- a/apps/ai-service/scripts/sandbox-agent.py +++ b/apps/ai-service/scripts/sandbox-agent.py @@ -565,6 +565,10 @@ class Agent: """Maintain the WS connection: connect, flush backlog, read, backoff.""" backoff = self.config.backoff_base_s while not self._stop.is_set(): + # P5: explicit Optional — the else-branch read can observe None + # (a concurrent _drop_conn between the check and the read), so + # the `conn is None` guard below is genuinely reachable. + conn: WsConnection | None if self._current_conn() is None: try: conn = WsConnection.connect(self.config.ingest_url) diff --git a/apps/ai-service/scripts/typecheck.sh b/apps/ai-service/scripts/typecheck.sh new file mode 100755 index 0000000..d72e473 --- /dev/null +++ b/apps/ai-service/scripts/typecheck.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Type check: mypy via venv — the D-058 relaxed-first baseline +# (check_untyped_defs, no_implicit_optional, warn_unused_ignores, +# warn_redundant_casts, warn_unreachable; config in pyproject.toml +# [tool.mypy], scope = ai_service + scripts/sandbox-agent.py + tests). +# +# Extra args pass through: `bash scripts/typecheck.sh --verbose`. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV="$APP_DIR/.venv" + +if [ ! -x "$VENV/bin/mypy" ]; then + echo "venv missing mypy — run scripts/bootstrap.sh (or pip install the dev extras) first" >&2 + exit 1 +fi + +cd "$APP_DIR" +exec "$VENV/bin/mypy" "$@" \ No newline at end of file diff --git a/apps/ai-service/tests/agents/test_corpus_inputs.py b/apps/ai-service/tests/agents/test_corpus_inputs.py index c589a37..edb4a3a 100644 --- a/apps/ai-service/tests/agents/test_corpus_inputs.py +++ b/apps/ai-service/tests/agents/test_corpus_inputs.py @@ -38,16 +38,22 @@ def test_lab_scenarios_addressable_by_id(): def test_lab_scenarios_have_distinct_event_profiles(): strong = get_lab_scenario("lab-scenario-strong") struggling = get_lab_scenario("lab-scenario-struggling") + flagged = get_lab_scenario("lab-scenario-flagged") + assert strong is not None, "seed scenario must resolve" + assert struggling is not None, "seed scenario must resolve" + assert flagged is not None, "seed scenario must resolve" def kinds(s): return {e.kind for e in s.events} assert "test_pass" in kinds(strong) assert "test_fail" in kinds(struggling) assert "idle" in kinds(struggling) - assert "paste" in kinds(get_lab_scenario("lab-scenario-flagged")) + assert "paste" in kinds(flagged) def test_summarize_scenario_mentions_events(): - text = summarize_scenario(get_lab_scenario("lab-scenario-struggling")) + scenario = get_lab_scenario("lab-scenario-struggling") + assert scenario is not None, "seed scenario must resolve" + text = summarize_scenario(scenario) assert "test_fail" in text assert "ImportError" in text assert "stack-orchestration-c002" in text @@ -80,6 +86,7 @@ def test_transcripts_pair_with_artifacts(): def test_rubric_render_mentions_all_criteria(): rubric = rubric_for_competency("stack-orchestration-c002") + assert rubric is not None, "seed rubric must resolve" text = render_rubric(rubric) for criterion in rubric.criteria: assert criterion.criterion_id in text @@ -87,6 +94,7 @@ def test_rubric_render_mentions_all_criteria(): def test_transcript_render_has_both_speakers(): transcript = get_transcript_for_artifact("art-eval-rag-dashboard") + assert transcript is not None, "seed transcript must resolve" text = render_transcript(transcript) assert "examiner:" in text assert "learner:" in text diff --git a/apps/ai-service/tests/agents/test_lab.py b/apps/ai-service/tests/agents/test_lab.py index fb77755..a35b678 100644 --- a/apps/ai-service/tests/agents/test_lab.py +++ b/apps/ai-service/tests/agents/test_lab.py @@ -25,9 +25,13 @@ async def test_stream_feedback_mentions_scenario_events(): ctx = get_learner_context() strong = get_lab_scenario("lab-scenario-strong") struggling = get_lab_scenario("lab-scenario-struggling") + assert strong is not None, "seed scenario must resolve" + assert struggling is not None, "seed scenario must resolve" - strong_reply = "".join([t async for t in lab.stream_feedback(strong, ctx)]) - struggling_reply = "".join([t async for t in lab.stream_feedback(struggling, ctx)]) + # P5 scoped: corpus scenario stands in for the TraceDigest (dormant- + # corpus test seam — render_digest_timeline duck-types model_dump_json). + strong_reply = "".join([t async for t in lab.stream_feedback(strong, ctx)]) # type: ignore[arg-type] + struggling_reply = "".join([t async for t in lab.stream_feedback(struggling, ctx)]) # type: ignore[arg-type] assert strong_reply assert strong_reply != struggling_reply # scenario-driven, not canned @@ -35,6 +39,7 @@ async def test_stream_feedback_mentions_scenario_events(): def test_build_evaluation_messages_carry_timeline(): lab = make_lab() scenario = get_lab_scenario("lab-scenario-flagged") + assert scenario is not None, "seed scenario must resolve" timeline = summarize_scenario(scenario) messages = lab.build_messages(None, timeline, get_learner_context()) assert messages[0].role == "system" diff --git a/apps/ai-service/tests/agents/test_proctor.py b/apps/ai-service/tests/agents/test_proctor.py index 3f185bc..02b8b44 100644 --- a/apps/ai-service/tests/agents/test_proctor.py +++ b/apps/ai-service/tests/agents/test_proctor.py @@ -49,7 +49,13 @@ async def test_assess_returns_validated_signals(): provider = ScriptedJSONProvider(VALID_ASSESSMENT) proctor = make_proctor(provider) scenario = get_proctor_scenario("proctor-scenario-distracted") - result = await proctor.assess(scenario, get_learner_context()) + assert scenario is not None, "seed scenario must resolve" + # P5 scoped: corpus scenario stands in for the TraceDigest (dormant- + # corpus test seam — the agent renders digest.model_dump_json). + result = await proctor.assess( + scenario, # type: ignore[arg-type] + learner_context=get_learner_context(), + ) assert isinstance(result, ProctorAssessment) assert len(result.signals) == 2 assert result.signals[0].severity == "low" @@ -59,8 +65,13 @@ async def test_assess_returns_validated_signals(): async def test_assess_rejects_invalid_after_retry(): proctor = make_proctor(MockProvider()) # non-schema JSON scenario = get_proctor_scenario("proctor-scenario-healthy") + assert scenario is not None, "seed scenario must resolve" with pytest.raises(StructuredOutputError): - await proctor.assess(scenario, get_learner_context()) + # P5 scoped: corpus scenario as digest — same dormant-corpus seam. + await proctor.assess( + scenario, # type: ignore[arg-type] + learner_context=get_learner_context(), + ) def test_system_prompt_is_coaching_not_punitive(): @@ -72,6 +83,7 @@ def test_system_prompt_is_coaching_not_punitive(): def test_timeline_summary_carries_events(): scenario = get_proctor_scenario("proctor-scenario-flagged") + assert scenario is not None, "seed scenario must resolve" text = summarize_proctor_scenario(scenario) assert "paste_large" in text assert "3,100 chars" in text diff --git a/apps/ai-service/tests/agents/test_session.py b/apps/ai-service/tests/agents/test_session.py index 2c0ab01..2ac6613 100644 --- a/apps/ai-service/tests/agents/test_session.py +++ b/apps/ai-service/tests/agents/test_session.py @@ -14,7 +14,9 @@ async def test_create_and_get(): store = InMemorySessionStore() session = await store.create("s1", agent="coach") assert session.agent == "coach" - assert (await store.get("s1")).session_id == "s1" + got = await store.get("s1") + assert got is not None + assert got.session_id == "s1" assert await store.get("missing") is None diff --git a/apps/ai-service/tests/agents/test_structured.py b/apps/ai-service/tests/agents/test_structured.py index 0d78d84..b120f45 100644 --- a/apps/ai-service/tests/agents/test_structured.py +++ b/apps/ai-service/tests/agents/test_structured.py @@ -69,7 +69,8 @@ async def test_structured_completion_retries_then_raises(): # Plain MockProvider returns non-schema JSON for json_object requests → # both attempts fail validation → StructuredOutputError after ONE retry. provider = MockProvider() - provider.received_calls = [] + # P5 scoped: test-only call ledger attribute on the shared mock. + provider.received_calls = [] # type: ignore[attr-defined] messages = [Message(role="user", content="grade me")] with pytest.raises(StructuredOutputError): await structured_completion( @@ -141,7 +142,7 @@ async def test_structured_completion_sends_schema_instruction(): messages, model=model, temperature=temperature, response_format=response_format ) - provider.chat = recording_chat + provider.chat = recording_chat # type: ignore[method-assign] # P5: monkeypatching the shared mock messages = [Message(role="user", content="grade")] await structured_completion(provider, messages, model="m", schema=Score, schema_hint=HINT) assert captured, "provider was never called" diff --git a/apps/ai-service/tests/api/test_assessment.py b/apps/ai-service/tests/api/test_assessment.py index c3815a5..44b6c39 100644 --- a/apps/ai-service/tests/api/test_assessment.py +++ b/apps/ai-service/tests/api/test_assessment.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Iterator from datetime import UTC, datetime import pytest @@ -33,7 +34,7 @@ class CoachingMock(MockProvider): @pytest.fixture() -def client(tmp_path) -> TestClient: +def client(tmp_path) -> Iterator[TestClient]: app = create_app(Settings(provider="mock")) app.state.provider = CoachingMock() app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db") @@ -45,8 +46,10 @@ def client(tmp_path) -> TestClient: def _seed_grade(client: TestClient) -> None: + # P5: TestClient.app is typed ASGIApp (Callable); at runtime it is the + # FastAPI instance — this state-injection seam is the suite's pattern. app = client.app - store: SQLiteGradeStore = app.state.grade_store + store: SQLiteGradeStore = app.state.grade_store # type: ignore[attr-defined] store.save( GradeRecord( learner_id="api-learner", diff --git a/apps/ai-service/tests/api/test_defense.py b/apps/ai-service/tests/api/test_defense.py index 9ba4692..1c40434 100644 --- a/apps/ai-service/tests/api/test_defense.py +++ b/apps/ai-service/tests/api/test_defense.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Iterator from pathlib import Path import pytest @@ -77,7 +78,7 @@ def app(tmp_path: Path): @pytest.fixture() -def client(app) -> TestClient: +def client(app) -> Iterator[TestClient]: with TestClient(app) as c: yield c diff --git a/apps/ai-service/tests/api/test_lab.py b/apps/ai-service/tests/api/test_lab.py index b73e54a..27f18f8 100644 --- a/apps/ai-service/tests/api/test_lab.py +++ b/apps/ai-service/tests/api/test_lab.py @@ -7,6 +7,7 @@ scenarios; empty trace is a valid "no telemetry yet" coaching path. from __future__ import annotations +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path @@ -44,7 +45,7 @@ class StreamingMock(MockProvider): @pytest.fixture() -def client(tmp_path: Path) -> TestClient: +def client(tmp_path: Path) -> Iterator[TestClient]: app = create_app(Settings(provider="mock")) app.state.provider = StreamingMock() app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db") @@ -55,7 +56,7 @@ def client(tmp_path: Path) -> TestClient: def test_live_trace_streams_full_envelope(client: TestClient) -> None: - store: SQLiteTraceStore = client.app.state.trace_store + store: SQLiteTraceStore = client.app.state.trace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI for e in [ _event(0, "file_diff", {"path": "x.py"}, 0), _event(1, "command", {"cmd": "pytest -q"}, 10), diff --git a/apps/ai-service/tests/api/test_proctor.py b/apps/ai-service/tests/api/test_proctor.py index 9b1113f..dad7e9e 100644 --- a/apps/ai-service/tests/api/test_proctor.py +++ b/apps/ai-service/tests/api/test_proctor.py @@ -7,6 +7,7 @@ and gathers digest + defense signals + variant context server-side. from __future__ import annotations import json +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path @@ -49,7 +50,7 @@ class BrokenJSON(MockProvider): @pytest.fixture() -def client(tmp_path: Path) -> TestClient: +def client(tmp_path: Path) -> Iterator[TestClient]: app = create_app(Settings(provider="mock")) app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db") app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db") @@ -61,7 +62,7 @@ def client(tmp_path: Path) -> TestClient: def _seed_trace(client: TestClient) -> None: - store: SQLiteTraceStore = client.app.state.trace_store + store: SQLiteTraceStore = client.app.state.trace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI for e in [ TelemetryEvent( learner_id="p-learner", @@ -86,7 +87,7 @@ def _seed_trace(client: TestClient) -> None: def test_signals_returns_validated_json(client: TestClient) -> None: - client.app.state.provider = ProctorJSON() + client.app.state.provider = ProctorJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI _seed_trace(client) resp = client.post( "/v1/proctor/signals", json={"learner_id": "p-learner", "task_id": "p-task"} @@ -99,7 +100,7 @@ def test_signals_returns_validated_json(client: TestClient) -> None: def test_empty_trace_is_valid_not_404(client: TestClient) -> None: """No telemetry → the proctor still assesses (nothing to flag).""" - client.app.state.provider = ProctorJSON() + client.app.state.provider = ProctorJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI resp = client.post( "/v1/proctor/signals", json={"learner_id": "nobody", "task_id": "nothing"} ) @@ -107,7 +108,7 @@ def test_empty_trace_is_valid_not_404(client: TestClient) -> None: def test_unparseable_provider_502(client: TestClient) -> None: - client.app.state.provider = BrokenJSON() + client.app.state.provider = BrokenJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI _seed_trace(client) resp = client.post( "/v1/proctor/signals", json={"learner_id": "p-learner", "task_id": "p-task"} @@ -116,6 +117,6 @@ def test_unparseable_provider_502(client: TestClient) -> None: def test_missing_fields_422(client: TestClient) -> None: - client.app.state.provider = ProctorJSON() + client.app.state.provider = ProctorJSON() # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI resp = client.post("/v1/proctor/signals", json={"learner_id": "x"}) assert resp.status_code == 422 diff --git a/apps/ai-service/tests/api/test_telemetry_ingest.py b/apps/ai-service/tests/api/test_telemetry_ingest.py index 5a3a590..791468d 100644 --- a/apps/ai-service/tests/api/test_telemetry_ingest.py +++ b/apps/ai-service/tests/api/test_telemetry_ingest.py @@ -530,10 +530,10 @@ def test_manager_urls_carry_valid_tokens(client: TestClient) -> None: accepts — mint/verify agree through the shared app.state instance.""" from urllib.parse import parse_qs, urlparse - manager = client.app.state.sandbox_manager + manager = client.app.state.sandbox_manager # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI env = manager._capture_env("sbx-tok-1", "learner-tok", "task-tok") parsed = urlparse(env["NC_INGEST_URL"]) qs = parse_qs(parsed.query) - assert qs["token"][0] == client.app.state.ingest_token.mint( + assert qs["token"][0] == client.app.state.ingest_token.mint( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI "learner-tok", "task-tok", "sbx-tok-1" ) diff --git a/apps/ai-service/tests/api/test_variants.py b/apps/ai-service/tests/api/test_variants.py index 84cfed9..217ee15 100644 --- a/apps/ai-service/tests/api/test_variants.py +++ b/apps/ai-service/tests/api/test_variants.py @@ -153,7 +153,9 @@ class TestGenerate: assert body["task_id"].startswith("task-") and len(body["task_id"]) == len("task-") + 16 assert body["seed"] # non-empty D-029 seed assert body["statement"] # rendered, non-empty - assert body["starter_files"] == get_template(TEMPLATE).starter_files + template = get_template(TEMPLATE) + assert template is not None, "seed template must resolve" + assert body["starter_files"] == template.starter_files assert body["params"] # seeded slot values assert body["created_at"] # ISO timestamp travels assert provider.calls == 1 # first POST renders exactly once diff --git a/apps/ai-service/tests/conftest.py b/apps/ai-service/tests/conftest.py index 4870ba6..207b891 100644 --- a/apps/ai-service/tests/conftest.py +++ b/apps/ai-service/tests/conftest.py @@ -2,7 +2,9 @@ import shutil import tempfile +from collections.abc import Iterator from pathlib import Path +from typing import Any import pytest from fastapi.testclient import TestClient @@ -44,7 +46,7 @@ def pytest_collection_modifyitems(config, items) -> None: @pytest.fixture() -def sandbox_dir(tmp_path: Path) -> Path: +def sandbox_dir(tmp_path: Path) -> Iterator[Path]: """Per-test sandbox workdir root on a bind-mount-safe filesystem.""" _HOME_ROOT.mkdir(parents=True, exist_ok=True) d = Path(tempfile.mkdtemp(prefix=f"nc-test-{tmp_path.name}-", dir=_HOME_ROOT)) @@ -53,7 +55,7 @@ def sandbox_dir(tmp_path: Path) -> Path: @pytest.fixture(autouse=True, scope="session") -def _sweep_home_test_sandboxes() -> None: +def _sweep_home_test_sandboxes() -> Iterator[None]: """P2 (Task 2-1-03, G-110): session-end sweep of the anchored test roots. Removes ONLY test-created roots, never a user's real state: @@ -156,7 +158,9 @@ def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings: @pytest.fixture() -def identity_store(tmp_path: Path): +def identity_store(tmp_path: Path) -> Iterator[Any]: + # P5: the yield-fixture's Iterator return is what mypy needs; the + # concrete type is imported INSIDE (lazy-import pattern preserved). from ai_service.identity.store import SQLiteIdentityStore store = SQLiteIdentityStore(db_path=tmp_path / "identity-test.db") diff --git a/apps/ai-service/tests/grading/test_calibration.py b/apps/ai-service/tests/grading/test_calibration.py index 629c601..e6b60cd 100644 --- a/apps/ai-service/tests/grading/test_calibration.py +++ b/apps/ai-service/tests/grading/test_calibration.py @@ -31,6 +31,7 @@ fails — that is the calibration signal. from __future__ import annotations import json +from collections.abc import Iterator from pathlib import Path import pytest @@ -131,14 +132,14 @@ class ArchetypeProvider: @pytest.fixture -def trace_store(tmp_path: Path) -> SQLiteTraceStore: +def trace_store(tmp_path: Path) -> Iterator[SQLiteTraceStore]: store = SQLiteTraceStore(db_path=tmp_path / "traces.db") yield store store.close() @pytest.fixture -def grade_store(tmp_path: Path) -> SQLiteGradeStore: +def grade_store(tmp_path: Path) -> Iterator[SQLiteGradeStore]: store = SQLiteGradeStore(db_path=tmp_path / "grades.db") yield store store.close() diff --git a/apps/ai-service/tests/grading/test_engine.py b/apps/ai-service/tests/grading/test_engine.py index fb7b737..4d9bf6a 100644 --- a/apps/ai-service/tests/grading/test_engine.py +++ b/apps/ai-service/tests/grading/test_engine.py @@ -19,6 +19,7 @@ Zero network: everything runs against the mock provider (conftest rule). from __future__ import annotations import json +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path @@ -127,14 +128,14 @@ def _complete_trace(marker_command: bool = True) -> list[TelemetryEvent]: @pytest.fixture -def trace_store(tmp_path: Path) -> SQLiteTraceStore: +def trace_store(tmp_path: Path) -> Iterator[SQLiteTraceStore]: store = SQLiteTraceStore(db_path=tmp_path / "traces.db") yield store store.close() @pytest.fixture -def grade_store(tmp_path: Path) -> SQLiteGradeStore: +def grade_store(tmp_path: Path) -> Iterator[SQLiteGradeStore]: store = SQLiteGradeStore(db_path=tmp_path / "grades.db") yield store store.close() diff --git a/apps/ai-service/tests/grading/test_store.py b/apps/ai-service/tests/grading/test_store.py index a8c0a11..875f5af 100644 --- a/apps/ai-service/tests/grading/test_store.py +++ b/apps/ai-service/tests/grading/test_store.py @@ -17,6 +17,7 @@ Each test gets its own tmp-path SQLite file — no shared disk state. Covers: import concurrent.futures import threading +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -61,7 +62,7 @@ def make_grade( @pytest.fixture -def store(tmp_path: Path) -> SQLiteGradeStore: +def store(tmp_path: Path) -> Iterator[SQLiteGradeStore]: s = SQLiteGradeStore(db_path=tmp_path / "grades.db") yield s s.close() diff --git a/apps/ai-service/tests/identity/test_identity.py b/apps/ai-service/tests/identity/test_identity.py index 7af3420..5bc1669 100644 --- a/apps/ai-service/tests/identity/test_identity.py +++ b/apps/ai-service/tests/identity/test_identity.py @@ -14,6 +14,7 @@ from __future__ import annotations import json import logging +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path @@ -38,7 +39,7 @@ def _age_band(dob: str) -> str: @pytest.fixture() -def identity_store(tmp_path: Path) -> SQLiteIdentityStore: +def identity_store(tmp_path: Path) -> Iterator[SQLiteIdentityStore]: store = SQLiteIdentityStore(db_path=tmp_path / "identity.db") yield store store.close() @@ -198,7 +199,7 @@ def await_(coro): @pytest.fixture() -def gated_client(tmp_path: Path, identity_store: SQLiteIdentityStore) -> TestClient: +def gated_client(tmp_path: Path, identity_store: SQLiteIdentityStore) -> Iterator[TestClient]: seed_verified_identity(identity_store) # suite roster verified 18+ settings = Settings( provider="mock", diff --git a/apps/ai-service/tests/llm/test_mock.py b/apps/ai-service/tests/llm/test_mock.py index b0583a3..95f98a2 100644 --- a/apps/ai-service/tests/llm/test_mock.py +++ b/apps/ai-service/tests/llm/test_mock.py @@ -62,7 +62,9 @@ async def test_cancellation_records_abort(): p = MockProvider() gen = p.stream_chat(MSGS, model="m") await gen.__anext__() - await gen.aclose() + # P5 scoped: async generators expose aclose(); the declared protocol + # return type (AsyncIterator) doesn't carry it in mypy's view. + await gen.aclose() # type: ignore[attr-defined] assert p.abort_recorded is True diff --git a/apps/ai-service/tests/marketplace/conftest.py b/apps/ai-service/tests/marketplace/conftest.py index 991756f..4585344 100644 --- a/apps/ai-service/tests/marketplace/conftest.py +++ b/apps/ai-service/tests/marketplace/conftest.py @@ -9,10 +9,12 @@ exact composition production runs. from __future__ import annotations +from collections.abc import Iterator from pathlib import Path import pytest from fastapi.testclient import TestClient +from pydantic import SecretStr from ai_service.config import Settings from ai_service.identity.store import SQLiteIdentityStore @@ -29,7 +31,7 @@ ADMIN_TOKEN = "test-admin-token" @pytest.fixture() -def marketplace_store(tmp_path: Path) -> SQLiteMarketplaceStore: +def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]: """Hermetic store: the shared-DB pattern on a pytest tmp file.""" store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db") yield store @@ -37,7 +39,7 @@ def marketplace_store(tmp_path: Path) -> SQLiteMarketplaceStore: @pytest.fixture() -def identity_store(tmp_path: Path) -> SQLiteIdentityStore: +def identity_store(tmp_path: Path) -> Iterator[SQLiteIdentityStore]: store = SQLiteIdentityStore(db_path=tmp_path / "identity.db") seed_verified_identity(store, MARKETPLACE_LEARNERS) yield store @@ -69,7 +71,8 @@ def make_client( learner_allowlist=SUITE_LEARNERS, db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token=admin_token or "", + # P5: Settings.admin_token is SecretStr (never logged); wrap explicitly. + admin_token=SecretStr(admin_token or ""), ) app = create_app(settings) app.state.identity_store = identity_store # state-injection (G-9) @@ -78,7 +81,7 @@ def make_client( @pytest.fixture(autouse=True) -def _reset_apply_rate_window(): +def _reset_apply_rate_window() -> Iterator[None]: """The apply rate cap is module-level process state (the D-019 precedent); tests must not inherit each other's windows.""" from ai_service.api.marketplace import _APPLY_TIMES @@ -93,7 +96,7 @@ def client( tmp_path: Path, identity_store: SQLiteIdentityStore, marketplace_store: SQLiteMarketplaceStore, -) -> TestClient: +) -> Iterator[TestClient]: with make_client( tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN ) as c: @@ -105,7 +108,7 @@ def seeded_client( tmp_path: Path, identity_store: SQLiteIdentityStore, marketplace_store: SQLiteMarketplaceStore, -) -> TestClient: +) -> Iterator[TestClient]: """Client over an INGESTED store (20 published seed postings).""" from ai_service.marketplace.ingest import SeededCorpusSource, run_ingest diff --git a/apps/ai-service/tests/marketplace/test_embedding.py b/apps/ai-service/tests/marketplace/test_embedding.py index 7ef9b14..d868960 100644 --- a/apps/ai-service/tests/marketplace/test_embedding.py +++ b/apps/ai-service/tests/marketplace/test_embedding.py @@ -23,6 +23,7 @@ import struct import httpx import pytest +from pydantic import SecretStr from ai_service.config import Settings from ai_service.marketplace.embedding import ( @@ -172,7 +173,9 @@ class TestVectorCodec: store = SQLiteMarketplaceStore(db_path=tmp_path / "embed.db") values = embed_text("python flask backend") store.put_vector("p", values, MOCK_EMBED_MODEL, MOCK_EMBED_DIM) - vals, model, dim = store.get_vector("p") + fetched = store.get_vector("p") + assert fetched is not None, "just-put vector must read back" + vals, model, dim = fetched store.close() assert model == MOCK_EMBED_MODEL assert dim == MOCK_EMBED_DIM @@ -212,7 +215,7 @@ class TestFactory: Settings( embed_provider="openai-embed", embed_base_url="https://e.example/v1", - embed_api_key="k", + embed_api_key=SecretStr("k"), embed_model="embed-model", ) ) @@ -222,7 +225,7 @@ class TestFactory: Settings( embed_provider="openai-embed", embed_base_url="https://e.example/v1", - embed_api_key="k", + embed_api_key=SecretStr("k"), embed_model="embed-model", ), httpx.AsyncClient(), diff --git a/apps/ai-service/tests/marketplace/test_ingest.py b/apps/ai-service/tests/marketplace/test_ingest.py index 3465833..97c7f5f 100644 --- a/apps/ai-service/tests/marketplace/test_ingest.py +++ b/apps/ai-service/tests/marketplace/test_ingest.py @@ -98,7 +98,10 @@ class TestSeededCorpusSource: """G-107/MH-1f: matchScore is absent from EVERY serialized posting — no camelCase, no snake_case, no variant.""" for posting in SeededCorpusSource().fetch(): - blob = json.dumps(posting_summary(posting)).lower() + # P5 scoped: the seed source yields IngestPosting (the adapter + # shape); posting_summary's model_dump works identically on both + # pydantic shapes — the G-107 pin covers the wire form. + blob = json.dumps(posting_summary(posting)).lower() # type: ignore[arg-type] assert "matchscore" not in blob assert "match_score" not in blob diff --git a/apps/ai-service/tests/marketplace/test_marketplace_api.py b/apps/ai-service/tests/marketplace/test_marketplace_api.py index b180246..22cec00 100644 --- a/apps/ai-service/tests/marketplace/test_marketplace_api.py +++ b/apps/ai-service/tests/marketplace/test_marketplace_api.py @@ -26,6 +26,7 @@ import logging from datetime import UTC, datetime from fastapi.testclient import TestClient +from pydantic import SecretStr from ai_service.api import marketplace as marketplace_api from ai_service.config import Settings @@ -55,7 +56,7 @@ class TestAdminFailClosed: learner_allowlist=["pilot-learner"], db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token="", # UNSET — the fail-closed case + admin_token=SecretStr(""), # UNSET — the fail-closed case ) app = create_app(settings) app.state.identity_store = identity @@ -620,7 +621,7 @@ class TestShellRoutesServeArbitraryIds: learner_allowlist=["pilot-learner"], db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token="", + admin_token=SecretStr(""), web_static_dir=web_dir, ) return TestClient(create_app(settings)) @@ -770,7 +771,7 @@ class TestAdminEditRoute: learner_allowlist=["pilot-learner"], db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token=ADMIN_TOKEN, + admin_token=SecretStr(ADMIN_TOKEN), ) app = create_app(settings) app.state.identity_store = identity_store @@ -831,7 +832,7 @@ class TestIngestTags: learner_allowlist=["pilot-learner"], db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token=ADMIN_TOKEN, + admin_token=SecretStr(ADMIN_TOKEN), ) app = create_app(settings) app.state.identity_store = identity_store @@ -871,11 +872,11 @@ class TestAdminTokenRobustness: provider="mock", db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "s", - admin_token=ADMIN_TOKEN, + admin_token=SecretStr(ADMIN_TOKEN), ) # The pre-fix code raised TypeError here (unhandled → 500). try: - require_admin("café-nonce-\u00e9", settings) # type: ignore[arg-type] + require_admin("café-nonce-\u00e9", settings) except HTTPException as exc: assert exc.status_code == 403 # honest rejection else: diff --git a/apps/ai-service/tests/marketplace/test_resume.py b/apps/ai-service/tests/marketplace/test_resume.py index a3c379c..c306125 100644 --- a/apps/ai-service/tests/marketplace/test_resume.py +++ b/apps/ai-service/tests/marketplace/test_resume.py @@ -11,6 +11,7 @@ import logging import pytest from fastapi.testclient import TestClient +from pydantic import SecretStr from ai_service.llm.mock import MockProvider, ScriptedJSONProvider from ai_service.marketplace.resume import ( @@ -77,7 +78,7 @@ class TestExtract: def test_unsupported_format_rejected(self) -> None: with pytest.raises(ResumeFormatError): - extract_resume_text(b"MZ\x90\x00", "application/vnd.exe") # type: ignore[arg-type] + extract_resume_text(b"MZ\x90\x00", "application/vnd.exe") def test_garbage_text_rejected(self) -> None: with pytest.raises(ResumeFormatError): @@ -122,7 +123,7 @@ class TestResumeRoutes: learner_allowlist=["pilot-learner"], db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token=ADMIN_TOKEN, + admin_token=SecretStr(ADMIN_TOKEN), ) app = create_app(settings) app.state.identity_store = identity_store diff --git a/apps/ai-service/tests/marketplace/test_search.py b/apps/ai-service/tests/marketplace/test_search.py index 28de98a..453155d 100644 --- a/apps/ai-service/tests/marketplace/test_search.py +++ b/apps/ai-service/tests/marketplace/test_search.py @@ -23,10 +23,12 @@ Pins: from __future__ import annotations import logging +from collections.abc import Iterator from datetime import UTC, datetime import pytest from fastapi.testclient import TestClient +from pydantic import SecretStr from ai_service.main import create_app from ai_service.marketplace.base import IngestPosting, Posting, PostingTags @@ -74,6 +76,7 @@ def _posting( title=title, description=description, skills=skills, + # P5: fixture accepts raw str; Posting's Literal contract. seniority=seniority, # type: ignore[arg-type] category=category, location=location, @@ -81,7 +84,7 @@ def _posting( salary_min=salary_min, salary_max=salary_max, status=status, # type: ignore[arg-type] - tags=PostingTags(skills=skills, seniority=seniority, category=category), # type: ignore[arg-type] + tags=PostingTags(skills=skills, seniority=seniority, category=category), # type: ignore[arg-type] # P5: same str-vs-Literal fixture skew posted_at=posted_at or datetime(2026, 9, 1, tzinfo=UTC), ) @@ -430,7 +433,7 @@ class TestSearchRoute: @pytest.fixture() def search_client( self, tmp_path, identity_store, marketplace_store - ) -> TestClient: + ) -> Iterator[TestClient]: """Client whose store is seeded, embedded, and ready to search.""" import asyncio as _asyncio @@ -639,7 +642,7 @@ class TestBootFallback: learner_allowlist=SUITE_LEARNERS, db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token=ADMIN_TOKEN, + admin_token=SecretStr(ADMIN_TOKEN), ) app = create_app(settings) app.state.identity_store = identity_store diff --git a/apps/ai-service/tests/marketplace/test_sitemap.py b/apps/ai-service/tests/marketplace/test_sitemap.py index 4311f00..84a451b 100644 --- a/apps/ai-service/tests/marketplace/test_sitemap.py +++ b/apps/ai-service/tests/marketplace/test_sitemap.py @@ -6,6 +6,7 @@ import logging import pytest from fastapi.testclient import TestClient +from pydantic import SecretStr from ai_service.api.sitemap import STATIC_PAGES from ai_service.config import Settings @@ -33,7 +34,7 @@ def make_web_client( learner_allowlist=MARKETPLACE_LEARNERS, db_path=tmp_path / "app.db", sandbox_dir=tmp_path / "sandboxes", - admin_token=ADMIN_TOKEN, + admin_token=SecretStr(ADMIN_TOKEN), public_base_url=public_base_url, **({"web_static_dir": web_dir} if web_dir else {}), ) @@ -146,7 +147,8 @@ class TestSitemap: assert "<script>" in body # escaped form present root = ET.fromstring(body) # still well-formed XML locs = [e.text for e in root.iter() if e.tag.endswith("loc")] - assert locs and locs[0].startswith("https://x.example\">") + assert locs and locs[0] is not None + assert locs[0].startswith("https://x.example\">") def test_static_pages_use_real_export_paths( self, tmp_path, ingested_store, identity @@ -241,6 +243,7 @@ class TestMountOrder: locs = [e.text for e in root.iter() if e.tag.endswith("loc")] assert locs, "sitemap enumerated nothing" for loc in locs: + assert loc is not None, "a sitemap is empty" path = loc.replace(ORIGIN, "") or "/" probe = path if path.endswith("/") else path + "/" assert client.get(probe).status_code == 200, ( diff --git a/apps/ai-service/tests/marketplace/test_store.py b/apps/ai-service/tests/marketplace/test_store.py index a2453b4..ddfde29 100644 --- a/apps/ai-service/tests/marketplace/test_store.py +++ b/apps/ai-service/tests/marketplace/test_store.py @@ -14,6 +14,7 @@ every other move. from __future__ import annotations from datetime import UTC, datetime +from typing import Any import pytest from sqlalchemy import text @@ -27,11 +28,11 @@ def _posting( external_id: str = "job-x", source: str = "seed-corpus", posting_id: str | None = None, - **overrides, + **overrides: Any, ) -> IngestPosting: """A valid IngestPosting; `posting_id` defaults to external_id (the D-021 shared-id convention: id == external_id for seed ports).""" - payload = dict( + payload: dict[str, Any] = dict( id=posting_id or external_id, source=source, external_id=external_id, @@ -149,7 +150,7 @@ class TestPostingConstraints: def test_validate_hooks(self, marketplace_store) -> None: with pytest.raises(ValueError, match="invalid posting status"): marketplace_store.upsert_posting( - _posting(), status="banana" # type: ignore[arg-type] + _posting(), status="banana" # P5: deliberate invalid-status probe ) with pytest.raises(ValueError, match="employer_id"): marketplace_store.upsert_posting( diff --git a/apps/ai-service/tests/marketplace/test_tagging.py b/apps/ai-service/tests/marketplace/test_tagging.py index 2351e0d..aaf6817 100644 --- a/apps/ai-service/tests/marketplace/test_tagging.py +++ b/apps/ai-service/tests/marketplace/test_tagging.py @@ -62,7 +62,7 @@ class TestTagPosting: attempts = {"n": 0} class RetryThenValid(MockProvider): - def _reply_for(self, messages, response_format): # type: ignore[override] + def _reply_for(self, messages, response_format): if response_format is not None and response_format.get("type") == "json_object": attempts["n"] += 1 if attempts["n"] == 1: @@ -102,7 +102,7 @@ class TestTagOnIngest: """json_object responses derived from the posting content — deterministic per posting.""" - def _reply_for(self, messages, response_format): # type: ignore[override] + def _reply_for(self, messages, response_format): import json if response_format is not None and response_format.get("type") == "json_object": @@ -146,6 +146,7 @@ class TestTagOnIngest: async def _tag_all() -> None: for pid in ("job-001", "job-002"): posting = store.get_posting(pid) + assert posting is not None, "hand-seeded posting must resolve" tags = await tag_posting( DeterministicTagger(), model=MODEL, diff --git a/apps/ai-service/tests/sandbox/conftest.py b/apps/ai-service/tests/sandbox/conftest.py index 5ed5d8d..a3fe799 100644 --- a/apps/ai-service/tests/sandbox/conftest.py +++ b/apps/ai-service/tests/sandbox/conftest.py @@ -28,6 +28,7 @@ the session for suites outside ``tests/sandbox/``) is handled there. from __future__ import annotations import shutil +from collections.abc import Iterator from pathlib import Path import pytest @@ -39,7 +40,7 @@ _SANDBOX_DROPPINGS_ROOT = Path(__file__).resolve().parents[1] / "sandboxes" @pytest.fixture(autouse=True, scope="session") -def _sweep_repo_sandbox_droppings() -> None: +def _sweep_repo_sandbox_droppings() -> Iterator[None]: """After the session's last test, remove the anchored sandbox root. ``rmtree(ignore_errors=True)``: the whole ``tests/sandboxes/`` tree is diff --git a/apps/ai-service/tests/sandbox/test_sandbox_agent.py b/apps/ai-service/tests/sandbox/test_sandbox_agent.py index c564e23..5501536 100644 --- a/apps/ai-service/tests/sandbox/test_sandbox_agent.py +++ b/apps/ai-service/tests/sandbox/test_sandbox_agent.py @@ -29,6 +29,7 @@ from collections.abc import Iterator from datetime import datetime from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +from typing import Any import pytest @@ -79,7 +80,11 @@ def _load_agent(): return mod -agent = _load_agent() +# P5: the sandbox agent is loaded from its hyphenated path at runtime +# (spec_from_file_location) — statically it is an opaque module, so the +# Any alias keeps the suite's agent.Agent/AgentConfig/Spool annotations +# checkable without pretending the dynamic load has static types. +agent: Any = _load_agent() # --------------------------------------------------------------------------- # Raw-socket frame helpers (test-harness mirror of the agent's codec) @@ -511,6 +516,7 @@ class TestFileDiffWatcher: test_agent.stop() created = _find_diff(test_agent, "hello.txt", "created") + assert created is not None, "created diff never observed" assert created["payload"]["diff"] # unified diff present assert "v1" in created["payload"]["diff"] diff --git a/apps/ai-service/tests/sandbox/test_telemetry_wiring.py b/apps/ai-service/tests/sandbox/test_telemetry_wiring.py index a764b85..4133351 100644 --- a/apps/ai-service/tests/sandbox/test_telemetry_wiring.py +++ b/apps/ai-service/tests/sandbox/test_telemetry_wiring.py @@ -86,7 +86,9 @@ async def test_task_sandbox_streams_events_to_ingest(tmp_path: Path, sandbox_dir ) return env - manager._capture_env = _capture_env + # P5 scoped: deliberate test seam — NC_INGEST_URL redirected to + # the proxy under test (the wiring probe's whole mechanism). + manager._capture_env = _capture_env # type: ignore[method-assign] handle = await manager.create(learner, task_id=task) try: diff --git a/apps/ai-service/tests/telemetry/test_durability.py b/apps/ai-service/tests/telemetry/test_durability.py index 05569a4..80ad9f3 100644 --- a/apps/ai-service/tests/telemetry/test_durability.py +++ b/apps/ai-service/tests/telemetry/test_durability.py @@ -88,11 +88,14 @@ async def _await_file_diffs( def _stored_diff_paths(store: SQLiteTraceStore, learner: str, task: str) -> set[str]: - return { - e.payload.get("path") - for e in store.get_trace(learner, task) - if e.kind == "file_diff" - } + paths: set[str] = set() + for e in store.get_trace(learner, task): + if e.kind != "file_diff": + continue + path = e.payload.get("path") + if isinstance(path, str): # P5: payload.get is object|None — filter to str + paths.add(path) + return paths @pytest.mark.asyncio @@ -147,14 +150,16 @@ async def test_disconnect_reconnect_loses_nothing(tmp_path: Path, sandbox_dir: P "NC_BACKOFF_MAX_S": "0.5", } - manager._capture_env = capture_env + # P5 scoped: deliberate test seam — the proxy URL replaces the real + # capture agent's env (the durability probe dials the KillableProxy). + manager._capture_env = capture_env # type: ignore[method-assign] try: async with serve(app, "127.0.0.1", server_port): handle = await manager.create(learner, task_id=task) try: live = manager._handles[handle.id] - backend: UnshareBackend = manager._backend + backend = manager._backend # Phase A — connected: a write streams through. result = await backend.exec(live, ["sh", "-c", "echo one > a.txt"]) @@ -258,14 +263,16 @@ async def test_midburst_disconnect_loses_nothing(tmp_path: Path, sandbox_dir: Pa "NC_BACKOFF_MAX_S": "0.5", } - manager._capture_env = capture_env + # P5 scoped: deliberate test seam — the proxy URL replaces the real + # capture agent's env (the durability probe dials the KillableProxy). + manager._capture_env = capture_env # type: ignore[method-assign] try: async with serve(app, "127.0.0.1", server_port): handle = await manager.create(learner, task_id=task) try: live = manager._handles[handle.id] - backend: UnshareBackend = manager._backend + backend = manager._backend # Phase A — connected: confirm the pipe works (1 event minimum). result = await backend.exec(live, ["sh", "-c", "echo warm > warm.txt"]) diff --git a/apps/ai-service/tests/telemetry/test_models.py b/apps/ai-service/tests/telemetry/test_models.py index 94701fe..1203f54 100644 --- a/apps/ai-service/tests/telemetry/test_models.py +++ b/apps/ai-service/tests/telemetry/test_models.py @@ -6,6 +6,7 @@ canonical kwargs builder. import json from datetime import UTC, datetime +from typing import get_args import pytest from pydantic import ValidationError @@ -123,4 +124,5 @@ def test_tracespan_rejects_empty_ids(): def test_event_kind_type_is_exported_literal(): # The kind discriminator is part of the public module surface. - assert "command" in EventKind.__args__ + # P5: get_args() is the typed accessor (bare __args__ trips mypy). + assert "command" in get_args(EventKind) diff --git a/apps/ai-service/tests/variants/test_environments.py b/apps/ai-service/tests/variants/test_environments.py index a9da1d2..60b4966 100644 --- a/apps/ai-service/tests/variants/test_environments.py +++ b/apps/ai-service/tests/variants/test_environments.py @@ -124,16 +124,13 @@ class TestExecPolicy: class ExecStubBackend(StubBackend): """StubBackend + a working exec (policy fires BEFORE exec).""" - async def exec(self, handle, cmd): # type: ignore[override] - from datetime import UTC, datetime - + async def exec(self, handle, cmd): return ExecResult( cmd=list(cmd), returncode=0, stdout="ok", stderr="", duration_s=0.0, - ts=datetime.now(UTC), ) vstore = SQLiteVariantStore(db_path=tmp_path / "v.db") @@ -167,18 +164,20 @@ class TestExecPolicy: "/v1/sandboxes", json={"learner_id": DESIGN_LEARNER, "task_id": var["task_id"]}, ).json() - c._sandbox_id = sbx["id"] # type: ignore[attr-defined] + c._sandbox_id = sbx["id"] # P5: private-attr seam the fixture sets; reads stay untyped yield c def test_design_kind_rejects_out_of_policy_command(self, exec_client) -> None: - sbx = exec_client._sandbox_id # type: ignore[attr-defined] + # P5: _sandbox_id was set on the client by the fixture (Any-typed seam). + sbx = exec_client._sandbox_id resp = exec_client.post(f"/v1/sandboxes/{sbx}/exec", json={"cmd": ["rm", "-rf", "/"]}) assert resp.status_code == 422 assert "'rm'" in resp.json()["detail"] assert "allowed" in resp.json()["detail"] def test_design_kind_rejects_shell_passthrough(self, exec_client) -> None: - sbx = exec_client._sandbox_id # type: ignore[attr-defined] + # P5: _sandbox_id was set on the client by the fixture (Any-typed seam). + sbx = exec_client._sandbox_id resp = exec_client.post( f"/v1/sandboxes/{sbx}/exec", json={"cmd": ["sh", "-c", "anything"]} ) @@ -189,7 +188,8 @@ class TestExecPolicy: """The policy passes the declared harness (StubBackend.exec raises NotImplementedError by design — any status EXCEPT 422 proves the policy allowed the command through).""" - sbx = exec_client._sandbox_id # type: ignore[attr-defined] + # P5: _sandbox_id was set on the client by the fixture (Any-typed seam). + sbx = exec_client._sandbox_id resp = exec_client.post( f"/v1/sandboxes/{sbx}/exec", json={"cmd": ["python3", "validate_flow.py"]}, diff --git a/apps/ai-service/tests/variants/test_generator.py b/apps/ai-service/tests/variants/test_generator.py index b781e62..8c038cd 100644 --- a/apps/ai-service/tests/variants/test_generator.py +++ b/apps/ai-service/tests/variants/test_generator.py @@ -26,7 +26,9 @@ class ScriptedRenderProvider(MockProvider): super().__init__() self.calls = 0 - async def chat(self, messages, model, response_format=None): + async def chat(self, messages, *, model, temperature=0.7, response_format=None): + # P5: keyword-only signature matches the MockProvider/LLMProvider + # protocol shape (the old positional `model` was an override skew). self.calls += 1 import json @@ -43,7 +45,7 @@ class FailingRenderProvider(MockProvider): super().__init__() self.calls = 0 - async def chat(self, messages, model, response_format=None): + async def chat(self, messages, *, model, temperature=0.7, response_format=None): self.calls += 1 return "this is not json at all" @@ -88,6 +90,7 @@ async def test_params_are_schema_valid(store) -> None: gen = VariantGenerator(store, ScriptedRenderProvider(), model="mock") record = await gen.generate("learner-a", "tpl-guardrail-schema") template = get_template("tpl-guardrail-schema") + # P5: get_template returns Optional (unknown id raises inside generate). for slot in template.slots: # type: ignore[union-attr] value = record.params[slot.name] assert slot.validate_value(value), f"slot {slot.name} drew invalid {value!r}" @@ -98,7 +101,7 @@ async def test_llm_failure_falls_back_deterministically(store) -> None: gen = VariantGenerator(store, provider, model="mock") record = await gen.generate("learner-a", "tpl-rag-chunker") template = get_template("tpl-rag-chunker") - expected = template.render({k: v for k, v in record.params.items()}) # type: ignore + expected = template.render({k: v for k, v in record.params.items()}) # type: ignore[union-attr] assert record.statement == expected # skeleton render, seed-auditable assert provider.calls == 2 # D-020 bounded retry, then fallback diff --git a/apps/ai-service/tests/variants/test_store.py b/apps/ai-service/tests/variants/test_store.py index 1de6f6a..dc9be77 100644 --- a/apps/ai-service/tests/variants/test_store.py +++ b/apps/ai-service/tests/variants/test_store.py @@ -20,6 +20,7 @@ Each test gets its own tmp-path SQLite file — no shared disk state. Covers: import concurrent.futures import threading +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -73,7 +74,7 @@ def make_variant( @pytest.fixture -def store(tmp_path: Path) -> SQLiteVariantStore: +def store(tmp_path: Path) -> Iterator[SQLiteVariantStore]: s = SQLiteVariantStore(db_path=tmp_path / "variants.db") yield s s.close() diff --git a/apps/ai-service/tests/variants/test_templates.py b/apps/ai-service/tests/variants/test_templates.py index deefe58..6244677 100644 --- a/apps/ai-service/tests/variants/test_templates.py +++ b/apps/ai-service/tests/variants/test_templates.py @@ -36,7 +36,10 @@ def test_int_range_slot_bounds() -> None: slot = ParameterSlot(name="n", type="int_range", lo=2, hi=4) rng = random.Random(0) for _ in range(20): - assert 2 <= slot.sample(rng) <= 4 + # P5: sample() returns str | int; int_range slots yield ints. + drawn = slot.sample(rng) + assert isinstance(drawn, int) + assert 2 <= drawn <= 4 assert slot.validate_value(3) assert not slot.validate_value(5) assert not slot.validate_value("3") diff --git a/apps/ai-service/tests/voice/test_defense_store.py b/apps/ai-service/tests/voice/test_defense_store.py index 340bc78..7da8f41 100644 --- a/apps/ai-service/tests/voice/test_defense_store.py +++ b/apps/ai-service/tests/voice/test_defense_store.py @@ -23,6 +23,7 @@ Each test gets its own tmp-path SQLite file — no shared disk state. Covers: - WAL + synchronous=NORMAL + foreign_keys=ON pragmas actually applied """ +from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path @@ -78,7 +79,7 @@ def make_turn( @pytest.fixture -def store(tmp_path: Path) -> SQLiteDefenseStore: +def store(tmp_path: Path) -> Iterator[SQLiteDefenseStore]: s = SQLiteDefenseStore(db_path=tmp_path / "defenses.db") yield s s.close() @@ -321,7 +322,7 @@ def test_finalize_rejects_non_dict_signals(store: SQLiteDefenseStore) -> None: store.start(make_defense()) with pytest.raises(ValueError, match="integrity_signals"): - store.finalize("defense-1", None) # type: ignore[arg-type] + store.finalize("defense-1", None) # type: ignore[arg-type] # P5: deliberate None probe (finalization contract) fetched = store.get("defense-1") assert fetched is not None diff --git a/apps/ai-service/tests/voice/test_latency.py b/apps/ai-service/tests/voice/test_latency.py index c2fad4d..f96bf83 100644 --- a/apps/ai-service/tests/voice/test_latency.py +++ b/apps/ai-service/tests/voice/test_latency.py @@ -14,6 +14,7 @@ not against its own definition. from __future__ import annotations +from collections.abc import Iterator from pathlib import Path import pytest @@ -37,7 +38,7 @@ DEFENSE_TURN_BUDGET_MS = 4_000 @pytest.fixture() -def client(tmp_path: Path) -> TestClient: +def client(tmp_path: Path) -> Iterator[TestClient]: from ai_service.identity.store import SQLiteIdentityStore from tests.conftest import SUITE_LEARNERS, seed_verified_identity diff --git a/apps/ai-service/tests/voice/test_openai_audio.py b/apps/ai-service/tests/voice/test_openai_audio.py index c3b1977..350701f 100644 --- a/apps/ai-service/tests/voice/test_openai_audio.py +++ b/apps/ai-service/tests/voice/test_openai_audio.py @@ -9,6 +9,7 @@ Cloud-free: the real endpoint is a manual probe recipe (.env.example). from __future__ import annotations import json +from typing import Any import httpx import pytest @@ -18,10 +19,10 @@ from ai_service.voice.openai_audio import OpenAIAudioProvider KEY = "sk-voice-test-xyz" -def make_provider(handler, **overrides) -> OpenAIAudioProvider: +def make_provider(handler, **overrides: Any) -> OpenAIAudioProvider: transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport) - kwargs = { + kwargs: dict[str, Any] = { "base_url": "https://voice.example/v1", "api_key": KEY, "stt_model": "whisper-1", diff --git a/apps/web/components/employer/analytics-charts.tsx b/apps/web/components/employer/analytics-charts.tsx index 3f56042..c7df715 100644 --- a/apps/web/components/employer/analytics-charts.tsx +++ b/apps/web/components/employer/analytics-charts.tsx @@ -113,7 +113,7 @@ export function AnalyticsCharts() { ))} - `${v}%`} /> + `${value}%`} /> diff --git a/apps/web/components/learner/progress-graph.tsx b/apps/web/components/learner/progress-graph.tsx index 91207e0..3bfa221 100644 --- a/apps/web/components/learner/progress-graph.tsx +++ b/apps/web/components/learner/progress-graph.tsx @@ -55,7 +55,7 @@ export function ProgressGraph() { /> [`${v}%`, 'Mastery']} + formatter={(value) => [`${value}%`, 'Mastery']} /> =16' - react: '>=16' - '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1617,68 +1608,23 @@ packages: webpack-plugin-serve: optional: true - '@storybook/addon-actions@8.6.14': - resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==} + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-backgrounds@8.6.14': - resolution: {integrity: sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-controls@8.6.14': - resolution: {integrity: sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-docs@8.6.14': - resolution: {integrity: sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-essentials@8.6.14': - resolution: {integrity: sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-highlight@8.6.14': - resolution: {integrity: sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-measure@8.6.14': - resolution: {integrity: sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-outline@8.6.14': - resolution: {integrity: sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-toolbars@8.6.14': - resolution: {integrity: sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/addon-viewport@8.6.14': - resolution: {integrity: sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA==} - peerDependencies: - storybook: ^8.6.14 - - '@storybook/blocks@8.6.14': - resolution: {integrity: sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^8.6.14 + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 peerDependenciesMeta: react: optional: true - react-dom: + react-redux: optional: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/builder-webpack5@10.6.0': resolution: {integrity: sha512-j71OmQZ/+j9S/FYa64tIRuSsXdVxU87zthJ+StKUan6m7wA88YyCAXzk2hAHQsfC9MA52A0kMcOPluNZzIh3xQ==} peerDependencies: @@ -1693,21 +1639,9 @@ packages: peerDependencies: storybook: ^10.6.0 - '@storybook/csf-plugin@8.6.14': - resolution: {integrity: sha512-dErtc9teAuN+eelN8FojzFE635xlq9cNGGGEu0WEmMUQ4iJ8pingvBO1N8X3scz4Ry7KnxX++NNf3J3gpxS8qQ==} - peerDependencies: - storybook: ^8.6.14 - '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - '@storybook/icons@1.6.0': - resolution: {integrity: sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - '@storybook/icons@2.1.0': resolution: {integrity: sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==} peerDependencies: @@ -1770,13 +1704,6 @@ packages: '@types/react-dom': optional: true - '@storybook/react-dom-shim@8.6.14': - resolution: {integrity: sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.6.14 - '@storybook/react@10.6.0': resolution: {integrity: sha512-0xfYYPRHcr5ta1hmXJCSezH4JGuUHJ+xVF9f1j+Y/MWEMOQE4wnaKWKsk/HLrDjZkT5hKfvmogt9b0yduP6zEQ==} peerDependencies: @@ -2033,9 +1960,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/mdx@2.0.14': - resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} - '@types/node@20.17.6': resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} @@ -2056,8 +1980,8 @@ packages: '@types/semver@7.8.0': resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} - '@types/uuid@9.0.8': - resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} '@typescript-eslint/eslint-plugin@8.70.1': resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} @@ -2798,9 +2722,6 @@ packages: dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -2882,6 +2803,9 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -2955,8 +2879,8 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} @@ -2968,10 +2892,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.4.2: - resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==} - engines: {node: '>=6.0.0'} - fast-json-parse@1.0.3: resolution: {integrity: sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==} @@ -3214,6 +3134,9 @@ packages: resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} + immer@11.1.18: + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3484,10 +3407,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3523,9 +3442,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - map-or-similar@1.5.0: - resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3537,9 +3453,6 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - memoizerific@1.11.3: - resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3708,10 +3621,6 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3861,10 +3770,6 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} - polished@4.3.1: - resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} - engines: {node: '>=10'} - possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3942,9 +3847,6 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} @@ -3991,31 +3893,28 @@ packages: peerDependencies: react: ^19.3.0 - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -4039,21 +3938,26 @@ packages: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.4: - resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} - engines: {node: '>=14'} - deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regenerate-unicode-properties@10.2.2: resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} engines: {node: '>=4'} @@ -4090,6 +3994,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4513,10 +4420,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unplugin@1.16.1: - resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} - engines: {node: '>=14.0.0'} - update-browserslist-db@1.3.2: resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true @@ -4544,17 +4447,12 @@ packages: utila@0.4.0: resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} @@ -5776,12 +5674,6 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@mdx-js/react@3.1.1(@types/react@19.3.0)(react@19.3.0)': - dependencies: - '@types/mdx': 2.0.14 - '@types/react': 19.3.0 - react: 19.3.0 - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -5967,92 +5859,21 @@ snapshots: type-fest: 2.19.0 webpack-hot-middleware: 2.26.1 - '@storybook/addon-actions@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)': dependencies: - '@storybook/global': 5.0.0 - '@types/uuid': 9.0.8 - dequal: 2.0.3 - polished: 4.3.1 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - uuid: 9.0.1 - - '@storybook/addon-backgrounds@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/global': 5.0.0 - memoizerific: 1.11.3 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - ts-dedent: 2.3.0 - - '@storybook/addon-controls@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/global': 5.0.0 - dequal: 2.0.3 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - ts-dedent: 2.3.0 - - '@storybook/addon-docs@8.6.14(@types/react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.3.0)(react@19.3.0) - '@storybook/blocks': 8.6.14(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/csf-plugin': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/react-dom-shim': 8.6.14(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - ts-dedent: 2.3.0 - transitivePeerDependencies: - - '@types/react' - - '@storybook/addon-essentials@8.6.14(@types/react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/addon-actions': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-backgrounds': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-controls': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-docs': 8.6.14(@types/react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-highlight': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-measure': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-outline': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-toolbars': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - '@storybook/addon-viewport': 8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - ts-dedent: 2.3.0 - transitivePeerDependencies: - - '@types/react' - - '@storybook/addon-highlight@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - - '@storybook/addon-measure@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - tiny-invariant: 1.3.3 - - '@storybook/addon-outline@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - ts-dedent: 2.3.0 - - '@storybook/addon-toolbars@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - - '@storybook/addon-viewport@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - memoizerific: 1.11.3 - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - - '@storybook/blocks@8.6.14(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - '@storybook/icons': 1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - ts-dedent: 2.3.0 + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.18 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 optionalDependencies: react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} '@storybook/builder-webpack5@10.6.0(clean-css@5.3.3)(esbuild@0.28.2)(html-minifier-terser@6.1.0)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@20.17.6))(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(typescript@5.9.3)': dependencies: @@ -6100,18 +5921,8 @@ snapshots: storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) ts-dedent: 2.3.0 - '@storybook/csf-plugin@8.6.14(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - unplugin: 1.16.1 - '@storybook/global@5.0.0': {} - '@storybook/icons@1.6.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - '@storybook/icons@2.1.0(react@19.3.0)': dependencies: react: 19.3.0 @@ -6255,12 +6066,6 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@storybook/react-dom-shim@8.6.14(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': - dependencies: - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - '@storybook/react@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 @@ -6514,8 +6319,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/mdx@2.0.14': {} - '@types/node@20.17.6': dependencies: undici-types: 6.19.8 @@ -6536,7 +6339,7 @@ snapshots: '@types/semver@7.8.0': {} - '@types/uuid@9.0.8': {} + '@types/use-sync-external-store@0.0.6': {} '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.11.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': dependencies: @@ -6765,13 +6568,13 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@xyflow/react@12.11.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(immer@11.1.18)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@xyflow/system': 0.0.82 classcat: 5.0.5 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - zustand: 4.5.7(@types/react@19.3.0)(react@19.3.0) + zustand: 4.5.7(@types/react@19.3.0)(immer@11.1.18)(react@19.3.0) optionalDependencies: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) @@ -7398,11 +7201,6 @@ snapshots: dependencies: utila: 0.4.0 - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.29.7 - csstype: 3.2.3 - dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -7489,6 +7287,8 @@ snapshots: dependencies: es-errors: 1.3.0 + es-toolkit@1.52.0: {} + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -7607,7 +7407,7 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -7618,8 +7418,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-equals@5.4.2: {} - fast-json-parse@1.0.3: {} fast-json-stable-stringify@2.1.0: {} @@ -7877,6 +7675,8 @@ snapshots: ignore@7.0.10: {} + immer@11.1.18: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -8103,10 +7903,6 @@ snapshots: lodash@4.18.1: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - loupe@3.2.1: {} lower-case@2.0.2: @@ -8141,8 +7937,6 @@ snapshots: dependencies: semver: 7.8.5 - map-or-similar@1.5.0: {} - math-intrinsics@1.1.0: {} md5.js@1.3.5: @@ -8155,10 +7949,6 @@ snapshots: dependencies: fs-monkey: 1.1.0 - memoizerific@1.11.3: - dependencies: - map-or-similar: 1.5.0 - merge-stream@2.0.0: {} micromatch@4.0.8: @@ -8300,8 +8090,6 @@ snapshots: dependencies: boolbase: 1.0.0 - object-assign@4.1.1: {} - object-inspect@1.13.4: {} object-is@1.1.6: @@ -8494,10 +8282,6 @@ snapshots: dependencies: find-up: 6.3.0 - polished@4.3.1: - dependencies: - '@babel/runtime': 7.29.7 - possible-typed-array-names@1.1.0: {} postcss-loader@8.2.1(postcss@8.5.28)(typescript@5.9.3)(webpack@5.110.3(clean-css@5.3.3)(esbuild@0.28.2)(html-minifier-terser@6.1.0)(lightningcss@1.32.0)(postcss@8.5.28)(sharp@0.35.4(@types/node@20.17.6))): @@ -8576,12 +8360,6 @@ snapshots: process@0.11.10: {} - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - public-encrypt@4.0.3: dependencies: bn.js: 4.12.5 @@ -8641,31 +8419,21 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 - react-is@16.13.1: {} - react-is@17.0.2: {} react-is@18.3.1: {} + react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.3.0 + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + redux: 5.0.1 + react-refresh@0.14.2: {} - react-smooth@4.0.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - fast-equals: 5.4.2 - prop-types: 15.8.1 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - react-transition-group: 4.4.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - - react-transition-group@4.4.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - '@babel/runtime': 7.29.7 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - react@19.3.0: {} readable-stream@2.3.8: @@ -8702,28 +8470,37 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@18.3.1)(react@19.3.0)(redux@5.0.1): dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0) clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.18.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.52.0 + eventemitter3: 5.0.4 + immer: 11.1.18 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - recharts-scale: 0.4.5 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1) + reselect: 5.2.0 tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 + use-sync-external-store: 1.7.0(react@19.3.0) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regenerate-unicode-properties@10.2.2: dependencies: regenerate: 1.4.2 @@ -8761,6 +8538,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.2.0: {} + resolve-from@4.0.0: {} resolve-url-loader@5.0.0: @@ -9173,11 +8952,6 @@ snapshots: universalify@2.0.1: {} - unplugin@1.16.1: - dependencies: - acorn: 8.18.0 - webpack-virtual-modules: 0.6.2 - update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: browserslist: 4.28.9 @@ -9209,15 +8983,13 @@ snapshots: utila@0.4.0: {} - uuid@9.0.1: {} - v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - victory-vendor@36.9.2: + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 '@types/d3-ease': 3.0.2 @@ -9363,9 +9135,10 @@ snapshots: zod@4.6.5: {} - zustand@4.5.7(@types/react@19.3.0)(react@19.3.0): + zustand@4.5.7(@types/react@19.3.0)(immer@11.1.18)(react@19.3.0): dependencies: use-sync-external-store: 1.7.0(react@19.3.0) optionalDependencies: '@types/react': 19.3.0 + immer: 11.1.18 react: 19.3.0 diff --git a/turbo.json b/turbo.json index d9696bf..d99d435 100644 --- a/turbo.json +++ b/turbo.json @@ -35,6 +35,10 @@ "cache": false, "outputs": [] }, + "@nextcraft/ai-service#typecheck": { + "cache": false, + "outputs": [] + }, "web#test": { "cache": false, "outputs": []