chore(P05): type & dependency hardening — mypy + recharts 3 (REQ-7-005)

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---
This commit is contained in:
CIAgent
2026-09-27 10:08:07 +00:00
parent 06f049ba37
commit dc00a4f8f5
70 changed files with 513 additions and 498 deletions
+3 -3
View File
@@ -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"
}
+11 -3
View File
@@ -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)
+9 -5
View File
@@ -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:
+5 -2
View File
@@ -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)
+5 -1
View File
@@ -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)
@@ -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
],
@@ -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,
+6 -2
View File
@@ -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 = (
+6 -2
View File
@@ -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(
+4 -2
View File
@@ -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:
@@ -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,
+11 -6
View File
@@ -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()
+5 -1
View File
@@ -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],
*,
+6 -2
View File
@@ -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)
+24 -15
View File
@@ -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]
@@ -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
@@ -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):
@@ -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)
)
+5 -3
View File
@@ -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:
@@ -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:
+7 -2
View File
@@ -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'"
@@ -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("/")
+2 -1
View File
@@ -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"
}
}
+38 -1
View File
@@ -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"]
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
+4
View File
@@ -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)
+19
View File
@@ -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" "$@"
@@ -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
+7 -2
View File
@@ -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"
+14 -2
View File
@@ -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
+3 -1
View File
@@ -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
@@ -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"
+5 -2
View File
@@ -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",
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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),
+7 -6
View File
@@ -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
@@ -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"
)
+3 -1
View File
@@ -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
+7 -3
View File
@@ -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")
@@ -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()
+3 -2
View File
@@ -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()
+2 -1
View File
@@ -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()
@@ -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",
+3 -1
View File
@@ -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
@@ -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
@@ -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(),
@@ -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
@@ -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:
@@ -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
@@ -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
@@ -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 "&lt;script&gt;" 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 <loc> is empty"
path = loc.replace(ORIGIN, "") or "/"
probe = path if path.endswith("/") else path + "/"
assert client.get(probe).status_code == 200, (
@@ -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(
@@ -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,
+2 -1
View File
@@ -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
@@ -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"]
@@ -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:
@@ -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"])
@@ -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)
@@ -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"]},
@@ -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
+2 -1
View File
@@ -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()
@@ -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")
@@ -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
+2 -1
View File
@@ -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
@@ -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",
@@ -113,7 +113,7 @@ export function AnalyticsCharts() {
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} formatter={(v: number) => `${v}%`} />
<Tooltip contentStyle={tooltipStyle} formatter={(value) => `${value}%`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
@@ -55,7 +55,7 @@ export function ProgressGraph() {
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(v: number) => [`${v}%`, 'Mastery']}
formatter={(value) => [`${value}%`, 'Mastery']}
/>
<Area
type="monotone"
+1 -2
View File
@@ -26,10 +26,9 @@
"next": "^15.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0"
"recharts": "^3"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.6.14",
"@storybook/nextjs": "^10.6.0",
"@storybook/react": "^10.6.0",
"@storybook/test": "^8.6.15",
+1
View File
@@ -18,6 +18,7 @@
"ai:test": "turbo run test --filter=@nextcraft/ai-service",
"ai:bootstrap": "turbo run bootstrap --filter=@nextcraft/ai-service",
"ai:lint": "turbo run lint --filter=@nextcraft/ai-service",
"ai:typecheck": "turbo run typecheck --filter=@nextcraft/ai-service",
"ai:test:fast": "cd apps/ai-service && FAST=1 bash scripts/test.sh",
"cli:dev": "pnpm --filter @nextcraft/cli dev",
"test": "turbo run test",
+107 -334
View File
@@ -164,7 +164,7 @@ importers:
version: link:../../packages/ui
'@xyflow/react':
specifier: ^12.3.5
version: 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)
version: 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)
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.3.0)
@@ -178,12 +178,9 @@ importers:
specifier: ^19.0.0
version: 19.3.0(react@19.3.0)
recharts:
specifier: ^2.15.0
version: 2.15.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
specifier: ^3
version: 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)
devDependencies:
'@storybook/addon-essentials':
specifier: ^8.6.14
version: 8.6.14(@types/react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))
'@storybook/nextjs':
specifier: ^10.6.0
version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(clean-css@5.3.3)(esbuild@0.28.2)(html-minifier-terser@6.1.0)(lightningcss@1.32.0)(next@15.5.25(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@20.17.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(sharp@0.35.4(@types/node@20.17.6))(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(supports-color@8.1.1)(type-fest@2.19.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(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)))
@@ -1286,12 +1283,6 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
'@types/react': '>=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
+4
View File
@@ -35,6 +35,10 @@
"cache": false,
"outputs": []
},
"@nextcraft/ai-service#typecheck": {
"cache": false,
"outputs": []
},
"web#test": {
"cache": false,
"outputs": []