From 042e37a1fc171e764375ed588b4843bd0ff44569 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 14:14:05 +0200 Subject: [PATCH 1/8] refactor(types): typed host contract for Pg* mixins + honest _execute return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PgStoreHost (declaration-only, TYPE_CHECKING-guarded) makes the implicit mixin->store contract explicit per DIP §5.1: every self._execute / self._conn / self.batch_pool call in the eight persistence mixins now checks against the real signature. _MaterializedCursor moves to pg_store_host.MaterializedCursor and _execute's declared return type stops lying (it never returned a psycopg.Cursor): rows are DictRow, so row["column"] access type-checks throughout the mixins. The honest fetchone() -> DictRow | None surfaced ten unchecked INSERT..RETURNING sites (previously typed as never-None); they now use MaterializedCursor.one(), which raises with the real cause when the database breaks the row-guaranteeing contract instead of failing later on a bare None. Also removes the five dead per-mixin _normalize_memory_row shims (PgMemoryStore's own definition always wins the MRO; the shims were unreachable) per §9. Pyright (standard, 1.1.410): 568-baseline measured 418 on main in the CI-equivalent env; this commit brings it to 229. Refs #197 Co-Authored-By: Claude Fable 5 --- mcp_server/infrastructure/pg_store.py | 74 +++-------- .../infrastructure/pg_store_auxiliary.py | 20 ++- .../infrastructure/pg_store_entities.py | 12 +- .../infrastructure/pg_store_entity_merge.py | 6 +- mcp_server/infrastructure/pg_store_host.py | 115 ++++++++++++++++++ mcp_server/infrastructure/pg_store_queries.py | 10 +- .../infrastructure/pg_store_receipts.py | 6 +- .../infrastructure/pg_store_relationships.py | 8 +- mcp_server/infrastructure/pg_store_rules.py | 12 +- mcp_server/infrastructure/pg_store_stats.py | 12 +- 10 files changed, 165 insertions(+), 110 deletions(-) create mode 100644 mcp_server/infrastructure/pg_store_host.py diff --git a/mcp_server/infrastructure/pg_store.py b/mcp_server/infrastructure/pg_store.py index fca9e97c..83674434 100644 --- a/mcp_server/infrastructure/pg_store.py +++ b/mcp_server/infrastructure/pg_store.py @@ -28,10 +28,11 @@ import psycopg from pgvector import Vector from pgvector.psycopg import register_vector -from psycopg.rows import dict_row +from psycopg.rows import DictRow, dict_row from psycopg_pool import ConnectionPool from mcp_server.infrastructure.pg_schema import get_all_ddl +from mcp_server.infrastructure.pg_store_host import MaterializedCursor from mcp_server.infrastructure.pg_store_auxiliary import PgAuxiliaryMixin from mcp_server.infrastructure.pg_store_entities import PgEntityMixin from mcp_server.infrastructure.pg_store_entity_merge import PgEntityMergeMixin @@ -93,49 +94,6 @@ def _get_database_url() -> str: return url -class _MaterializedCursor: - """Lightweight cursor surrogate that pre-fetches rows. - - Phase 5: connections are checked out from the pool per query and - returned at the end of ``_execute``. The original ``psycopg.Cursor`` - becomes unusable once the connection is returned to the pool, so we - eagerly read all rows into memory here and expose the subset of the - Cursor API that production code actually uses: ``fetchone``, - ``fetchall``, and ``rowcount``. - """ - - __slots__ = ("_rows", "_idx", "_rowcount") - - def __init__(self, cursor: psycopg.Cursor) -> None: - self._rowcount = cursor.rowcount - try: - self._rows = cursor.fetchall() - except (psycopg.ProgrammingError, TypeError): - # DDL / DML statements without a result set — fetchall raises. - self._rows = [] - self._idx = 0 - - def fetchone(self) -> dict | None: - if self._idx >= len(self._rows): - return None - row = self._rows[self._idx] - self._idx += 1 - return row - - def fetchall(self) -> list: - remaining = self._rows[self._idx :] - self._idx = len(self._rows) - return remaining - - @property - def rowcount(self) -> int: - return self._rowcount - - def __iter__(self): - while (row := self.fetchone()) is not None: - yield row - - def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -185,16 +143,16 @@ def __init__(self, database_url: str | None = None) -> None: register_vector(self._conn) # Phase 5 pools — lazy-constructed; opening on first acquire # avoids paying pool-open cost for short-lived usages (tests). - self._interactive_pool: ConnectionPool | None = None - self._batch_pool: ConnectionPool | None = None + self._interactive_pool: ConnectionPool[psycopg.Connection[DictRow]] | None = None + self._batch_pool: ConnectionPool[psycopg.Connection[DictRow]] | None = None - def _create_connection(self) -> psycopg.Connection: + def _create_connection(self) -> psycopg.Connection[DictRow]: """Create a new database connection.""" return psycopg.connect(self._url, row_factory=dict_row, autocommit=True) # ── Phase 5: connection pools ──────────────────────────────────────── - def _configure_pool_connection(self, conn: psycopg.Connection) -> None: + def _configure_pool_connection(self, conn: psycopg.Connection[DictRow]) -> None: """Pool callback: set up each checked-out connection. Registers the pgvector adapter so callers can bind `vector` params. @@ -203,7 +161,7 @@ def _configure_pool_connection(self, conn: psycopg.Connection) -> None: """ register_vector(conn) - def _open_interactive_pool(self) -> ConnectionPool: + def _open_interactive_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: """Open the hot-path pool on first use.""" settings = get_memory_settings() @@ -218,7 +176,7 @@ def _open_interactive_pool(self) -> ConnectionPool: ) return pool - def _open_batch_pool(self) -> ConnectionPool: + def _open_batch_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: """Open the batch/long-running pool on first use.""" settings = get_memory_settings() @@ -234,7 +192,7 @@ def _open_batch_pool(self) -> ConnectionPool: return pool @property - def interactive_pool(self) -> ConnectionPool: + def interactive_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: """Hot-path ConnectionPool for recall / remember / anchor / etc. See docs/program/phase-5-pool-admission-design.md §1.1 for the @@ -245,7 +203,7 @@ def interactive_pool(self) -> ConnectionPool: return self._interactive_pool @property - def batch_pool(self) -> ConnectionPool: + def batch_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: """Batch/long-running ConnectionPool for consolidate / wiki_pipeline / ingest / seed_project / backfill_memories. @@ -256,7 +214,7 @@ def batch_pool(self) -> ConnectionPool: return self._batch_pool @contextmanager - def acquire_interactive(self) -> Iterator[psycopg.Connection]: + def acquire_interactive(self) -> Iterator[psycopg.Connection[DictRow]]: """Context manager borrowing a connection from the interactive pool. Use this for short-lived hot-path operations. For long-running @@ -272,7 +230,7 @@ def acquire_interactive(self) -> Iterator[psycopg.Connection]: yield conn @contextmanager - def acquire_batch(self) -> Iterator[psycopg.Connection]: + def acquire_batch(self) -> Iterator[psycopg.Connection[DictRow]]: """Context manager borrowing a connection from the batch pool.""" if get_memory_settings().POOL_DISABLED: @@ -304,7 +262,7 @@ def _reconnect(self) -> None: def _execute( self, query: str | psycopg.sql.Composable, params: Any = None, **kwargs: Any - ) -> psycopg.Cursor: + ) -> MaterializedCursor: """Execute a query with stale-plan recovery and reconnection. Phase 5: borrows a connection from ``interactive_pool`` for each @@ -330,11 +288,11 @@ def _execute( def _execute_on_conn( self, - conn: psycopg.Connection, + conn: psycopg.Connection[DictRow], query: str | psycopg.sql.Composable, params: Any, **kwargs: Any, - ) -> psycopg.Cursor: + ) -> MaterializedCursor: """Run a query on a given connection with retry-on-stale-plan. Returns a materialized cursor (rows pre-fetched) so callers can @@ -359,7 +317,7 @@ def _execute_on_conn( except psycopg.OperationalError: logger.warning("Database connection lost on pool checkout, retrying") cur = conn.execute(query, params, **kwargs) - return _MaterializedCursor(cur) + return MaterializedCursor(cur) # Advisory lock id for schema bootstrap. Two processes hitting a # fresh DB simultaneously (e.g. http_standalone + a worker subproc) diff --git a/mcp_server/infrastructure/pg_store_auxiliary.py b/mcp_server/infrastructure/pg_store_auxiliary.py index 25bb363c..9f0c0587 100644 --- a/mcp_server/infrastructure/pg_store_auxiliary.py +++ b/mcp_server/infrastructure/pg_store_auxiliary.py @@ -2,6 +2,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + import json from typing import TYPE_CHECKING, Any @@ -9,15 +11,9 @@ import psycopg -class PgAuxiliaryMixin: +class PgAuxiliaryMixin(PgStoreHost): """Prospective memory, checkpoint, archive, engram operations on PostgreSQL.""" - _conn: psycopg.Connection - - def _normalize_memory_row(self, row: dict) -> dict: - """Provided by PgMemoryStore.""" - return dict(row) - # ── Ingest checkpoint (resumable streaming ingest) ──────────────── def get_ingest_progress(self, run_id: str) -> tuple[str, int]: @@ -76,7 +72,7 @@ def insert_prospective_memory(self, data: dict[str, Any]) -> int: data.get("created_by", ""), data.get("source_memory_id"), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] @@ -137,7 +133,7 @@ def upsert_procedural_skill(self, data: dict[str, Any]) -> int: data.get("proficiency", 0.0), data.get("is_habitual", False), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] @@ -175,7 +171,7 @@ def insert_checkpoint(self, data: dict[str, Any]) -> int: data.get("custom_context", ""), data.get("epoch", 0), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] @@ -221,7 +217,7 @@ def insert_archive(self, data: dict[str, Any]) -> int: data.get("mismatch_score", 0.0), data.get("archive_reason", ""), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] @@ -342,7 +338,7 @@ def insert_schema(self, data: dict[str, Any]) -> int: data.get("assimilation_count", 0), data.get("violation_count", 0), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] except psycopg.errors.UniqueViolation: diff --git a/mcp_server/infrastructure/pg_store_entities.py b/mcp_server/infrastructure/pg_store_entities.py index 09f16efa..92944326 100644 --- a/mcp_server/infrastructure/pg_store_entities.py +++ b/mcp_server/infrastructure/pg_store_entities.py @@ -2,6 +2,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + from typing import TYPE_CHECKING, Any from mcp_server.shared.entity_canonical import canonicalize_entity_name @@ -9,15 +11,9 @@ import psycopg -class PgEntityMixin: +class PgEntityMixin(PgStoreHost): """Entity persistence operations on PostgreSQL.""" - _conn: psycopg.Connection - - def _normalize_memory_row(self, row: dict) -> dict: - """Provided by PgMemoryStore.""" - return dict(row) - def update_entities_heat_batch(self, updates: list[tuple[int, float]]) -> int: """Batch-update entity heat. Single round-trip, single commit. @@ -89,7 +85,7 @@ def insert_entity(self, data: dict[str, Any]) -> int: data.get("created_at"), data.get("heat", 1.0), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] diff --git a/mcp_server/infrastructure/pg_store_entity_merge.py b/mcp_server/infrastructure/pg_store_entity_merge.py index 333f1b43..e08a16d9 100644 --- a/mcp_server/infrastructure/pg_store_entity_merge.py +++ b/mcp_server/infrastructure/pg_store_entity_merge.py @@ -10,6 +10,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -19,11 +21,9 @@ _MERGE_PAIR_COUNT = 2 -class PgEntityMergeMixin: +class PgEntityMergeMixin(PgStoreHost): """Atomic entity collapse on PostgreSQL.""" - _conn: psycopg.Connection - def merge_entities(self, survivor_id: int, alias_id: int) -> dict[str, Any]: """Collapse ``alias_id`` into ``survivor_id`` in one transaction. diff --git a/mcp_server/infrastructure/pg_store_host.py b/mcp_server/infrastructure/pg_store_host.py new file mode 100644 index 00000000..ee028e05 --- /dev/null +++ b/mcp_server/infrastructure/pg_store_host.py @@ -0,0 +1,115 @@ +"""Typed host contract + materialized cursor for the PostgreSQL store mixins. + +``PgMemoryStore`` is assembled from eight persistence mixins, each of which +calls back into shared machinery the composed class provides (``_execute``, +``_conn``, ``batch_pool``). Before this module existed that contract was +implicit — every mixin accessed ``self._execute`` with no declaration, so the +type checker could not verify a single call against the real signature, and a +wrong call (or a renamed host method) surfaced at runtime instead of in CI. + +``PgStoreHost`` makes the contract explicit: mixins inherit it, the +declarations live under ``TYPE_CHECKING`` so the class is empty at runtime +(zero MRO behavior change), and ``PgMemoryStore`` remains the sole runtime +implementer. This is the DIP form §5.1 of the coding standard asks for: the +mixins (consumers) name the abstraction they need; the store supplies it. + +``MaterializedCursor`` lives here (moved from ``pg_store.py``) so both the +host contract and the store can name the concrete return type of ``_execute`` +without an import cycle. Its rows are ``DictRow`` — every connection this +backend opens uses ``row_factory=dict_row`` — which is what lets the type +checker verify ``row["column"]`` access in the mixins. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterator + +import psycopg + +if TYPE_CHECKING: + from psycopg.rows import DictRow + + +class MaterializedCursor: + """Lightweight cursor surrogate that pre-fetches rows. + + Phase 5: connections are checked out from the pool per query and + returned at the end of ``_execute``. The original ``psycopg.Cursor`` + becomes unusable once the connection is returned to the pool, so we + eagerly read all rows into memory here and expose the subset of the + Cursor API that production code actually uses: ``fetchone``, + ``fetchall``, and ``rowcount``. + """ + + __slots__ = ("_rows", "_idx", "_rowcount") + + def __init__(self, cursor: psycopg.Cursor[DictRow]) -> None: + self._rowcount = cursor.rowcount + try: + self._rows: list[DictRow] = cursor.fetchall() + except (psycopg.ProgrammingError, TypeError): + # DDL / DML statements without a result set — fetchall raises. + self._rows = [] + self._idx = 0 + + def fetchone(self) -> DictRow | None: + if self._idx >= len(self._rows): + return None + row = self._rows[self._idx] + self._idx += 1 + return row + + def one(self) -> DictRow: + """Return the next row, raising when the statement produced none. + + For statements whose contract guarantees a row (``INSERT ... + RETURNING``, aggregate ``SELECT count(*)``). A missing row there + means the database broke its side of the contract, so this raises + immediately with the real cause instead of letting the caller fail + later on a ``None`` with no context. + """ + row = self.fetchone() + if row is None: + raise psycopg.ProgrammingError( + "statement guaranteed a row (RETURNING/aggregate) but produced none" + ) + return row + + def fetchall(self) -> list[DictRow]: + remaining = self._rows[self._idx :] + self._idx = len(self._rows) + return remaining + + @property + def rowcount(self) -> int: + return self._rowcount + + def __iter__(self) -> Iterator[DictRow]: + while (row := self.fetchone()) is not None: + yield row + + +class PgStoreHost: + """Static contract every ``Pg*Mixin`` relies on. + + Declaration-only: the ``TYPE_CHECKING`` guard keeps the class empty at + runtime, so inheriting it changes no MRO lookup and adds no behavior. + ``PgMemoryStore`` provides every member listed here. + """ + + if TYPE_CHECKING: + from psycopg_pool import ConnectionPool + + _conn: psycopg.Connection[DictRow] + + @property + def batch_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: ... + + def _execute( + self, + query: str | psycopg.sql.Composable, + params: Any = None, + **kwargs: Any, + ) -> MaterializedCursor: ... + + def _normalize_memory_row(self, row: dict[str, Any]) -> dict[str, Any]: ... diff --git a/mcp_server/infrastructure/pg_store_queries.py b/mcp_server/infrastructure/pg_store_queries.py index 6845370d..60a65a6e 100644 --- a/mcp_server/infrastructure/pg_store_queries.py +++ b/mcp_server/infrastructure/pg_store_queries.py @@ -2,6 +2,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + import json from typing import TYPE_CHECKING, Any, Iterator from mcp_server.infrastructure.memory_config import get_memory_settings @@ -11,15 +13,9 @@ import psycopg -class PgQueryMixin: +class PgQueryMixin(PgStoreHost): """Read-only memory queries on PostgreSQL.""" - _conn: psycopg.Connection - - def _normalize_memory_row(self, row: dict) -> dict: - """Provided by PgMemoryStore.""" - return dict(row) - def get_memories_for_domain( self, domain: str, diff --git a/mcp_server/infrastructure/pg_store_receipts.py b/mcp_server/infrastructure/pg_store_receipts.py index b265ce24..ba1c2c9c 100644 --- a/mcp_server/infrastructure/pg_store_receipts.py +++ b/mcp_server/infrastructure/pg_store_receipts.py @@ -17,6 +17,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + # Single data-modifying-CTE statement on purpose: the store's # ``_execute`` borrows a pool connection per call, so two separate # INSERTs could land on two connections and lose header/items atomicity. @@ -87,7 +89,7 @@ def insert_receipt_on_connection( ) -class PgReceiptsMixin: +class PgReceiptsMixin(PgStoreHost): """Append-only injection receipts (blame path T1).""" def insert_injection_receipt( @@ -99,7 +101,7 @@ def insert_injection_receipt( """Insert one receipt header + its items atomically; return receipt_id.""" row = self._execute( _INSERT_RECEIPT_SQL, _receipt_params(channel, items, session_id) - ).fetchone() + ).one() self._conn.commit() return int(row["receipt_id"]) diff --git a/mcp_server/infrastructure/pg_store_relationships.py b/mcp_server/infrastructure/pg_store_relationships.py index a0fbe3f6..22542e15 100644 --- a/mcp_server/infrastructure/pg_store_relationships.py +++ b/mcp_server/infrastructure/pg_store_relationships.py @@ -2,17 +2,17 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import psycopg -class PgRelationshipMixin: +class PgRelationshipMixin(PgStoreHost): """Relationship persistence operations on PostgreSQL.""" - _conn: psycopg.Connection - def update_relationships_weight_batch( self, updates: list[tuple[int, float]] ) -> int: @@ -77,7 +77,7 @@ def insert_relationship(self, data: dict[str, Any]) -> int: data.get("confidence", 1.0), data.get("created_at"), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] diff --git a/mcp_server/infrastructure/pg_store_rules.py b/mcp_server/infrastructure/pg_store_rules.py index 3d7fda01..0cd8f625 100644 --- a/mcp_server/infrastructure/pg_store_rules.py +++ b/mcp_server/infrastructure/pg_store_rules.py @@ -2,6 +2,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -9,15 +11,9 @@ from psycopg import sql -class PgRuleMixin: +class PgRuleMixin(PgStoreHost): """Memory rule persistence operations on PostgreSQL.""" - _conn: psycopg.Connection - - def _normalize_memory_row(self, row: dict) -> dict: - """Provided by PgMemoryStore.""" - return dict(row) - def insert_rule(self, data: dict[str, Any]) -> int: row = self._execute( "INSERT INTO memory_rules " @@ -34,7 +30,7 @@ def insert_rule(self, data: dict[str, Any]) -> int: data.get("is_active", True), data.get("source_memory_id"), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] diff --git a/mcp_server/infrastructure/pg_store_stats.py b/mcp_server/infrastructure/pg_store_stats.py index 42c4b07b..968e8ce9 100644 --- a/mcp_server/infrastructure/pg_store_stats.py +++ b/mcp_server/infrastructure/pg_store_stats.py @@ -2,6 +2,8 @@ from __future__ import annotations +from mcp_server.infrastructure.pg_store_host import PgStoreHost + from typing import TYPE_CHECKING, Any import psycopg @@ -10,15 +12,9 @@ import psycopg -class PgStatsMixin: +class PgStatsMixin(PgStoreHost): """Diagnostics, consolidation stages, CLS queries on PostgreSQL.""" - _conn: psycopg.Connection - - def _normalize_memory_row(self, row: dict) -> dict: - """Provided by PgMemoryStore.""" - return dict(row) - # ── Counts ──────────────────────────────────────────────────────── def count_memories(self) -> dict[str, int]: @@ -330,7 +326,7 @@ def log_consolidation(self, data: dict[str, Any]) -> int: data.get("memories_archived", 0), data.get("duration_ms", 0), ), - ).fetchone() + ).one() self._conn.commit() return row["id"] From aecac583c57b74a5d745bd9f7f80d5a7f0771f78 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 14:36:35 +0200 Subject: [PATCH 2/8] =?UTF-8?q?fix(types):=20burn=20the=20infrastructure/?= =?UTF-8?q?=20pyright=20backlog=20to=20zero=20=E2=80=94=20cross-backend=20?= =?UTF-8?q?conn=20contract,=20compat=20executemany=20parity,=20lastrowid?= =?UTF-8?q?=20honesty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreConnection (db_types.py): the 16 shared query modules (pg_store_wiki_*, pg_store_memory_*, near_dup, lesson_promotion) were annotated psycopg-only while also serving PsycopgCompatConnection on SQLite; the union names the real contract, so both halves type-check at every call site. - Latent bug: _CompatExecutingCursor had no executemany, so upsert_page_sources (wiki page provenance) raised AttributeError on the SQLite backend — same silent-degradation class as #206. Added with SQL translation. - Latent bug: pipeline_installer accepted a rust_install result whose action claimed success but carried no cargo path, flowing None into the build argv; now refused as missing_toolchain. - lastrowid honesty: sqlite3's lastrowid is int|None; the compat layer claimed int. Insert paths now raise ProgrammingError on the broken contract instead of masking it (one pre-existing type: ignore removed). - MCPClient declares _extra_allowed_commands (was runtime attr injection + getattr fallback — §7.2); ap_bridge/mcp_client_pool assignments now type-check. - psycopg LiteralString query typing: batch/staging sink SQL pinned as LiteralString; allowlist-gated dynamic SQL (dedup/concepts/drafts) re-asserts literalness via cast at the S608-documented sites; get_all_ddl re-asserts it once after comment-stripping. - wiki_stats raises on an impossible empty aggregate instead of returning None as dict; _entry_row narrows its tuple union via match (len()-vs-constant cannot narrow); dead hasattr-fallback branches removed where the compat cursor already guarantees dict rows; msvcrt/fcntl split now guarded by sys.platform so the checker resolves it per-platform. pyright (standard, 1.1.410): 229 -> 125; mcp_server/infrastructure/ = 0. Refs #197 Co-Authored-By: Claude Fable 5 --- mcp_server/infrastructure/batch_sinks.py | 13 +++-- mcp_server/infrastructure/db_types.py | 29 ++++++++++ mcp_server/infrastructure/mcp_client.py | 8 ++- mcp_server/infrastructure/pg_schema.py | 12 +++- mcp_server/infrastructure/pg_store.py | 55 ++++++++++++------- .../infrastructure/pg_store_auxiliary.py | 2 +- .../infrastructure/pg_store_entities.py | 2 +- .../infrastructure/pg_store_entity_merge.py | 2 +- mcp_server/infrastructure/pg_store_host.py | 6 +- .../pg_store_lesson_promotion.py | 6 +- .../infrastructure/pg_store_memory_dedup.py | 11 ++-- .../infrastructure/pg_store_memory_domain.py | 8 +-- .../infrastructure/pg_store_memory_reheat.py | 6 +- .../pg_store_memory_write_class.py | 8 ++- .../infrastructure/pg_store_near_dup.py | 8 +-- mcp_server/infrastructure/pg_store_queries.py | 2 +- .../infrastructure/pg_store_relationships.py | 2 +- mcp_server/infrastructure/pg_store_rules.py | 1 - .../pg_store_wiki_citation_seed.py | 6 +- .../infrastructure/pg_store_wiki_claims.py | 20 ++++--- .../infrastructure/pg_store_wiki_concepts.py | 17 +++--- .../infrastructure/pg_store_wiki_domain.py | 6 +- .../infrastructure/pg_store_wiki_drafts.py | 24 ++++---- .../infrastructure/pg_store_wiki_links.py | 10 ++-- .../infrastructure/pg_store_wiki_notes.py | 21 ++++--- .../infrastructure/pg_store_wiki_pages.py | 12 ++-- .../infrastructure/pg_store_wiki_sources.py | 25 ++++----- .../infrastructure/pg_store_wiki_thermo.py | 10 ++-- .../infrastructure/pipeline_install_lock.py | 6 +- .../infrastructure/pipeline_installer.py | 11 +++- mcp_server/infrastructure/sqlite_compat.py | 18 +++++- mcp_server/infrastructure/sqlite_store.py | 8 ++- .../infrastructure/sqlite_store_entities.py | 3 + .../infrastructure/sqlite_store_mood.py | 9 +-- .../infrastructure/sqlite_store_receipts.py | 13 ++++- .../sqlite_store_relationships.py | 3 + .../infrastructure/sqlite_store_search.py | 4 +- .../infrastructure/staging_resolve_sink.py | 13 +++-- 38 files changed, 267 insertions(+), 153 deletions(-) create mode 100644 mcp_server/infrastructure/db_types.py diff --git a/mcp_server/infrastructure/batch_sinks.py b/mcp_server/infrastructure/batch_sinks.py index 84b2882b..7172f487 100644 --- a/mcp_server/infrastructure/batch_sinks.py +++ b/mcp_server/infrastructure/batch_sinks.py @@ -13,6 +13,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing_extensions import LiteralString + from typing import Any from mcp_server.infrastructure.pooled_sink import ( @@ -29,11 +34,11 @@ def __init__( self, acquire: ConnectAcquire, *, - copy_sql: str, + copy_sql: LiteralString, row_adapter: RowAdapter | None = None, ) -> None: super().__init__(acquire, row_adapter) - self._copy_sql = copy_sql + self._copy_sql: LiteralString = copy_sql def write_batch(self, batch: list[Any]) -> int: if not batch: @@ -60,11 +65,11 @@ def __init__( self, acquire: ConnectAcquire, *, - insert_sql: str, + insert_sql: LiteralString, row_adapter: RowAdapter | None = None, ) -> None: super().__init__(acquire, row_adapter) - self._insert_sql = insert_sql + self._insert_sql: LiteralString = insert_sql def write_batch(self, batch: list[Any]) -> int: if not batch: diff --git a/mcp_server/infrastructure/db_types.py b/mcp_server/infrastructure/db_types.py new file mode 100644 index 00000000..d2e609a2 --- /dev/null +++ b/mcp_server/infrastructure/db_types.py @@ -0,0 +1,29 @@ +"""Cross-backend connection type for shared query modules. + +The wiki / memory query modules (``pg_store_wiki_*``, ``pg_store_memory_*``, +``pg_store_near_dup``, ``pg_store_lesson_promotion``) take a ``conn`` +parameter that at runtime is either a psycopg connection (PostgreSQL) or a +``PsycopgCompatConnection`` (SQLite, issue #206). Annotating those parameters +as psycopg's ``Connection`` alone was a type lie: it switched checking off +for every SQLite call path — the class of blindness behind the silent +wiki-pipeline failure of issue #220. ``StoreConnection`` names the real +contract once, so both halves are checked at every call site. + +TYPE_CHECKING-only: this module is imported for annotations, never at +runtime, so it stays importable on installs without the ``[postgresql]`` +extra. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import TypeAlias + + from psycopg import Connection + from psycopg.rows import DictRow + + from mcp_server.infrastructure.sqlite_compat import PsycopgCompatConnection + + StoreConnection: TypeAlias = Connection[DictRow] | PsycopgCompatConnection diff --git a/mcp_server/infrastructure/mcp_client.py b/mcp_server/infrastructure/mcp_client.py index 7cc1e0af..5533b62f 100644 --- a/mcp_server/infrastructure/mcp_client.py +++ b/mcp_server/infrastructure/mcp_client.py @@ -35,6 +35,10 @@ def __init__(self, config: dict): self._server_info: dict | None = None self._negotiated_version: str | None = None self._connected = False + # Extra binaries allowed beyond _ALLOWED_COMMANDS (CWE-78 allowlist). + # Callers (ap_bridge, mcp_client_pool) extend this for upstream + # servers whose binaries the default list cannot know. + self._extra_allowed_commands: set[str] = set() self._connect_timeout_ms = config.get("connectTimeoutMs") or 10000 # callTimeoutMs: positive int = ms, 0 or None = no per-call timeout # (used for long-running upstream indexing). @@ -134,9 +138,7 @@ async def _spawn_process(self) -> None: # Validate command against allowlist (CWE-78 mitigation). # In test/dev, extra commands can be allowed via _extra_allowed_commands. - allowed = self._ALLOWED_COMMANDS | getattr( - self, "_extra_allowed_commands", set() - ) + allowed = self._ALLOWED_COMMANDS | self._extra_allowed_commands base_cmd = raw_command.split("/")[-1] if "/" in raw_command else raw_command if base_cmd not in allowed: raise McpConnectionError( diff --git a/mcp_server/infrastructure/pg_schema.py b/mcp_server/infrastructure/pg_schema.py index 8f8f7ebe..6cf73383 100644 --- a/mcp_server/infrastructure/pg_schema.py +++ b/mcp_server/infrastructure/pg_schema.py @@ -8,6 +8,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from typing_extensions import LiteralString + # ── Extensions ──────────────────────────────────────────────────────────── EXTENSIONS_DDL = """ @@ -2424,7 +2429,7 @@ def _split_statements(ddl: str) -> list[str]: return statements -def get_all_ddl() -> list[str]: +def get_all_ddl() -> list[LiteralString]: """Return all DDL as individual statements for safe per-statement execution. Each statement can be executed independently — if one fails, the @@ -2471,4 +2476,7 @@ def get_all_ddl() -> list[str]: result: list[str] = [] for block in blocks: result.extend(_split_statements(block)) - return result + # Every element derives from the module-literal *_DDL blocks above; + # _split_statements only strips comments/whitespace (regex ops erase + # LiteralString provenance for the checker, so it is re-asserted here). + return cast("list[LiteralString]", result) diff --git a/mcp_server/infrastructure/pg_store.py b/mcp_server/infrastructure/pg_store.py index 83674434..24536226 100644 --- a/mcp_server/infrastructure/pg_store.py +++ b/mcp_server/infrastructure/pg_store.py @@ -22,10 +22,11 @@ import os from contextlib import contextmanager from datetime import datetime, timezone -from typing import Any, Iterator +from typing import TYPE_CHECKING, Any, Iterator, cast import numpy as np import psycopg +from psycopg import sql from pgvector import Vector from pgvector.psycopg import register_vector from psycopg.rows import DictRow, dict_row @@ -45,6 +46,9 @@ from mcp_server.infrastructure.memory_config import get_memory_settings from mcp_server.core.temporal import normalize_date_to_iso +if TYPE_CHECKING: + from typing_extensions import LiteralString + logger = logging.getLogger(__name__) @@ -62,7 +66,7 @@ def compute_ddl_hash() -> str: return hashlib.sha256("\n".join(get_all_ddl()).encode("utf-8")).hexdigest() -def read_schema_hash(conn: psycopg.Connection) -> str | None: +def read_schema_hash(conn: psycopg.Connection[DictRow]) -> str | None: """Read the recorded DDL hash from ``schema_meta`` on ``conn``, or None. Pre: ``conn`` is a live psycopg connection opened with @@ -143,12 +147,16 @@ def __init__(self, database_url: str | None = None) -> None: register_vector(self._conn) # Phase 5 pools — lazy-constructed; opening on first acquire # avoids paying pool-open cost for short-lived usages (tests). - self._interactive_pool: ConnectionPool[psycopg.Connection[DictRow]] | None = None + self._interactive_pool: ConnectionPool[psycopg.Connection[DictRow]] | None = ( + None + ) self._batch_pool: ConnectionPool[psycopg.Connection[DictRow]] | None = None def _create_connection(self) -> psycopg.Connection[DictRow]: """Create a new database connection.""" - return psycopg.connect(self._url, row_factory=dict_row, autocommit=True) + return psycopg.Connection[DictRow].connect( + self._url, row_factory=dict_row, autocommit=True + ) # ── Phase 5: connection pools ──────────────────────────────────────── @@ -165,7 +173,7 @@ def _open_interactive_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: """Open the hot-path pool on first use.""" settings = get_memory_settings() - pool = ConnectionPool( + pool: ConnectionPool[psycopg.Connection[DictRow]] = ConnectionPool( conninfo=self._url, min_size=settings.POOL_INTERACTIVE_MIN, max_size=settings.POOL_INTERACTIVE_MAX, @@ -180,7 +188,7 @@ def _open_batch_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: """Open the batch/long-running pool on first use.""" settings = get_memory_settings() - pool = ConnectionPool( + pool: ConnectionPool[psycopg.Connection[DictRow]] = ConnectionPool( conninfo=self._url, min_size=settings.POOL_BATCH_MIN, max_size=settings.POOL_BATCH_MAX, @@ -261,7 +269,7 @@ def _reconnect(self) -> None: register_vector(self._conn) def _execute( - self, query: str | psycopg.sql.Composable, params: Any = None, **kwargs: Any + self, query: str | sql.Composable, params: Any = None, **kwargs: Any ) -> MaterializedCursor: """Execute a query with stale-plan recovery and reconnection. @@ -289,7 +297,7 @@ def _execute( def _execute_on_conn( self, conn: psycopg.Connection[DictRow], - query: str | psycopg.sql.Composable, + query: str | sql.Composable, params: Any, **kwargs: Any, ) -> MaterializedCursor: @@ -299,8 +307,14 @@ def _execute_on_conn( keep using .fetchone() / .fetchall() after the connection is returned to the pool. """ + # Single trust boundary for psycopg's LiteralString query typing: + # every str reaching here is either a module literal or an + # allowlist-gated build whose mechanism its site names under the + # ruff S608 gate (docs/ASSURANCE-CASE.md §5) — values always travel + # separately as bound params. + typed_query = cast("LiteralString | sql.SQL | sql.Composed", query) try: - cur = conn.execute(query, params, **kwargs) + cur = conn.execute(typed_query, params, **kwargs) except psycopg.errors.FeatureNotSupported: logger.info("Stale prepared plan detected, deallocating and retrying") try: @@ -313,10 +327,10 @@ def _execute_on_conn( logger.debug( "DEALLOCATE ALL during stale-plan recovery failed: %s", exc ) - cur = conn.execute(query, params, **kwargs) + cur = conn.execute(typed_query, params, **kwargs) except psycopg.OperationalError: logger.warning("Database connection lost on pool checkout, retrying") - cur = conn.execute(query, params, **kwargs) + cur = conn.execute(typed_query, params, **kwargs) return MaterializedCursor(cur) # Advisory lock id for schema bootstrap. Two processes hitting a @@ -553,7 +567,9 @@ def _build_insert_params(self, data: dict[str, Any]) -> dict[str, Any]: "write_class": data.get("write_class") or "deliberate", } - def _insert_memory_on(self, conn: psycopg.Connection, data: dict[str, Any]) -> int: + def _insert_memory_on( + self, conn: psycopg.Connection[DictRow], data: dict[str, Any] + ) -> int: """Run the memory INSERT on ``conn`` WITHOUT committing. The caller owns the transaction boundary: insert_memory() commits on a @@ -578,7 +594,7 @@ def insert_memory(self, data: dict[str, Any]) -> int: return int(row["id"]) def _current_chain_head( - self, conn: psycopg.Connection, target_id: int + self, conn: psycopg.Connection[DictRow], target_id: int ) -> int | None: """Walk ``target_id``'s supersession chain to its open head. @@ -651,7 +667,7 @@ def supersede_atomic( @staticmethod def _transfer_anchor_on( - conn: psycopg.Connection, head_id: int, new_id: int + conn: psycopg.Connection[DictRow], head_id: int, new_id: int ) -> None: """Anchor follows the chain head at supersession (decision 2026-07-07). @@ -1318,11 +1334,12 @@ def get_embeddings_for_memories(self, memory_ids: list[int]) -> dict[int, bytes] "WHERE id = ANY(%s::int[]) AND embedding IS NOT NULL", ([int(m) for m in memory_ids],), ).fetchall() - return { - int(r["id"]): self._vector_to_bytes(r["embedding"]) - for r in rows - if r.get("embedding") is not None - } + out: dict[int, bytes] = {} + for r in rows: + emb = self._vector_to_bytes(r.get("embedding")) + if emb is not None: + out[int(r["id"])] = emb + return out def get_temporal_co_access( self, diff --git a/mcp_server/infrastructure/pg_store_auxiliary.py b/mcp_server/infrastructure/pg_store_auxiliary.py index 9f0c0587..2442704e 100644 --- a/mcp_server/infrastructure/pg_store_auxiliary.py +++ b/mcp_server/infrastructure/pg_store_auxiliary.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - import psycopg + pass class PgAuxiliaryMixin(PgStoreHost): diff --git a/mcp_server/infrastructure/pg_store_entities.py b/mcp_server/infrastructure/pg_store_entities.py index 92944326..6a021251 100644 --- a/mcp_server/infrastructure/pg_store_entities.py +++ b/mcp_server/infrastructure/pg_store_entities.py @@ -8,7 +8,7 @@ from mcp_server.shared.entity_canonical import canonicalize_entity_name if TYPE_CHECKING: - import psycopg + pass class PgEntityMixin(PgStoreHost): diff --git a/mcp_server/infrastructure/pg_store_entity_merge.py b/mcp_server/infrastructure/pg_store_entity_merge.py index e08a16d9..54d3a8d9 100644 --- a/mcp_server/infrastructure/pg_store_entity_merge.py +++ b/mcp_server/infrastructure/pg_store_entity_merge.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - import psycopg + pass # source: structural — the id-lookup fetches exactly the survivor + alias pair _MERGE_PAIR_COUNT = 2 diff --git a/mcp_server/infrastructure/pg_store_host.py b/mcp_server/infrastructure/pg_store_host.py index ee028e05..6ca65a6b 100644 --- a/mcp_server/infrastructure/pg_store_host.py +++ b/mcp_server/infrastructure/pg_store_host.py @@ -25,9 +25,11 @@ from typing import TYPE_CHECKING, Any, Iterator import psycopg +from psycopg import sql if TYPE_CHECKING: from psycopg.rows import DictRow + from psycopg_pool import ConnectionPool class MaterializedCursor: @@ -98,8 +100,6 @@ class PgStoreHost: """ if TYPE_CHECKING: - from psycopg_pool import ConnectionPool - _conn: psycopg.Connection[DictRow] @property @@ -107,7 +107,7 @@ def batch_pool(self) -> ConnectionPool[psycopg.Connection[DictRow]]: ... def _execute( self, - query: str | psycopg.sql.Composable, + query: str | sql.Composable, params: Any = None, **kwargs: Any, ) -> MaterializedCursor: ... diff --git a/mcp_server/infrastructure/pg_store_lesson_promotion.py b/mcp_server/infrastructure/pg_store_lesson_promotion.py index 13152f30..175dbcdc 100644 --- a/mcp_server/infrastructure/pg_store_lesson_promotion.py +++ b/mcp_server/infrastructure/pg_store_lesson_promotion.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection # Shared by both queries below so the eligibility definition cannot drift @@ -34,7 +34,7 @@ def list_lesson_promotion_candidates( - conn: Connection, limit: int = 20 + conn: StoreConnection, limit: int = 20 ) -> list[dict[str, Any]]: """List active lesson/lesson-candidate memories with usage evidence. @@ -64,7 +64,7 @@ def list_lesson_promotion_candidates( return list(cur.fetchall()) -def count_lesson_promotion_candidates(conn: Connection) -> int: +def count_lesson_promotion_candidates(conn: StoreConnection) -> int: """Total count of lesson-promotion candidates, unbounded by any limit. Precondition: same as ``list_lesson_promotion_candidates``. diff --git a/mcp_server/infrastructure/pg_store_memory_dedup.py b/mcp_server/infrastructure/pg_store_memory_dedup.py index 0c2323bf..7385d3ae 100644 --- a/mcp_server/infrastructure/pg_store_memory_dedup.py +++ b/mcp_server/infrastructure/pg_store_memory_dedup.py @@ -27,10 +27,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: - from psycopg import Connection + from typing_extensions import LiteralString + from mcp_server.infrastructure.db_types import StoreConnection # Per-run scan cap on GROUP MEMBER ROWS (not groups) — mirrors @@ -50,7 +51,7 @@ def _dup_key_expr(column: str) -> str: return f"md5(lower(regexp_replace({column}, '\\s+', ' ', 'g')))" -def list_exact_duplicate_groups(conn: Connection, limit: int) -> list[dict]: +def list_exact_duplicate_groups(conn: StoreConnection, limit: int) -> list[dict]: """Every member row of every active exact-duplicate group. Pre-condition: ``limit`` bounds the number of member rows returned @@ -116,12 +117,12 @@ def list_exact_duplicate_groups(conn: Connection, limit: int) -> list[dict]: LIMIT %(limit)s """ # noqa: S608 — expression from hardcoded identifiers only (documented contract at _dup_key_expr); values are bound parameters (docs/ASSURANCE-CASE.md §5) with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql, {"limit": limit}) + cur.execute(cast("LiteralString", sql), {"limit": limit}) return list(cur.fetchall()) def supersede_to_existing( - conn: Connection, duplicate_id: int, survivor_id: int + conn: StoreConnection, duplicate_id: int, survivor_id: int ) -> bool: """Point ``duplicate_id``'s ``superseded_by_id`` at ``survivor_id``, CAS-guarded, without inserting any row. diff --git a/mcp_server/infrastructure/pg_store_memory_domain.py b/mcp_server/infrastructure/pg_store_memory_domain.py index a849b9b2..2977f21d 100644 --- a/mcp_server/infrastructure/pg_store_memory_domain.py +++ b/mcp_server/infrastructure/pg_store_memory_domain.py @@ -21,14 +21,14 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection _ORPHAN_TAG = "domain-orphan" def list_domainless_memories( - conn: Connection, limit: int, *, include_orphans: bool = False + conn: StoreConnection, limit: int, *, include_orphans: bool = False ) -> list[dict]: """Active (chain-head) memories whose domain is empty, with evidence. @@ -73,7 +73,7 @@ def list_domainless_memories( return list(cur.fetchall()) -def update_memory_domain(conn: Connection, memory_id: int, domain: str) -> bool: +def update_memory_domain(conn: StoreConnection, memory_id: int, domain: str) -> bool: """Set one memory's domain, but only if it is still empty. Pre-condition: ``memory_id`` refers to an existing ``memories`` row; @@ -103,7 +103,7 @@ def update_memory_domain(conn: Connection, memory_id: int, domain: str) -> bool: return cur.rowcount > 0 -def tag_memory_orphan(conn: Connection, memory_id: int) -> bool: +def tag_memory_orphan(conn: StoreConnection, memory_id: int) -> bool: """Append the ``domain-orphan`` tag, but only if the domain is still empty and the tag isn't already present. diff --git a/mcp_server/infrastructure/pg_store_memory_reheat.py b/mcp_server/infrastructure/pg_store_memory_reheat.py index 149154ea..958d64af 100644 --- a/mcp_server/infrastructure/pg_store_memory_reheat.py +++ b/mcp_server/infrastructure/pg_store_memory_reheat.py @@ -25,7 +25,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection from mcp_server.core.write_class import ( @@ -57,7 +57,7 @@ def list_deliberate_below_target( - conn: Connection, target: float, limit: int + conn: StoreConnection, target: float, limit: int ) -> list[dict]: """Every active deliberate memory whose ``effective_heat`` < ``target``. @@ -140,7 +140,7 @@ def list_deliberate_below_target( def apply_reheat( - conn: Connection, memory_id: int, old_heat_base: float, new_heat_base: float + conn: StoreConnection, memory_id: int, old_heat_base: float, new_heat_base: float ) -> bool: """Raise ``memory_id``'s ``heat_base`` to ``new_heat_base``, CAS-guarded. diff --git a/mcp_server/infrastructure/pg_store_memory_write_class.py b/mcp_server/infrastructure/pg_store_memory_write_class.py index c42ab36d..565e3c4f 100644 --- a/mcp_server/infrastructure/pg_store_memory_write_class.py +++ b/mcp_server/infrastructure/pg_store_memory_write_class.py @@ -37,13 +37,13 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection _DEFAULT_SENTINEL = "deliberate" -def list_source_groups_at_default(conn: Connection, limit: int) -> list[dict]: +def list_source_groups_at_default(conn: StoreConnection, limit: int) -> list[dict]: """Distinct ``source`` values among active rows still at the column DEFAULT, with their row counts. @@ -76,7 +76,9 @@ def list_source_groups_at_default(conn: Connection, limit: int) -> list[dict]: return list(cur.fetchall()) -def bulk_reclassify_source(conn: Connection, source: str, target_class: str) -> int: +def bulk_reclassify_source( + conn: StoreConnection, source: str, target_class: str +) -> int: """Rewrite every active, still-default row sharing ``source`` to ``target_class``. diff --git a/mcp_server/infrastructure/pg_store_near_dup.py b/mcp_server/infrastructure/pg_store_near_dup.py index b670e663..e403b542 100644 --- a/mcp_server/infrastructure/pg_store_near_dup.py +++ b/mcp_server/infrastructure/pg_store_near_dup.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection from mcp_server.core.near_dup_calibration import SCAN_FLOOR, CandidatePair @@ -50,7 +50,7 @@ def list_candidate_pairs( - conn: Connection, + conn: StoreConnection, *, top_k: int = DEFAULT_TOP_K, min_similarity: float = SCAN_FLOOR, @@ -116,7 +116,7 @@ def list_candidate_pairs( ] -def fetch_contents(conn: Connection, ids: list[int]) -> dict[int, str]: +def fetch_contents(conn: StoreConnection, ids: list[int]) -> dict[int, str]: """Content text for a set of memory ids (for labeling artifacts). Post-condition: returns ``{id: content}`` for every id in ``ids`` @@ -138,7 +138,7 @@ def fetch_contents(conn: Connection, ids: list[int]) -> dict[int, str]: return {row["id"]: row["content"] for row in cur.fetchall()} -def fetch_member_stats(conn: Connection, ids: list[int]) -> dict[int, dict]: +def fetch_member_stats(conn: StoreConnection, ids: list[int]) -> dict[int, dict]: """``effective_heat`` and ``created_at`` for a set of memory ids (for survivor election across a near-dup component). diff --git a/mcp_server/infrastructure/pg_store_queries.py b/mcp_server/infrastructure/pg_store_queries.py index 60a65a6e..1a7e1784 100644 --- a/mcp_server/infrastructure/pg_store_queries.py +++ b/mcp_server/infrastructure/pg_store_queries.py @@ -10,7 +10,7 @@ import numpy as np if TYPE_CHECKING: - import psycopg + pass class PgQueryMixin(PgStoreHost): diff --git a/mcp_server/infrastructure/pg_store_relationships.py b/mcp_server/infrastructure/pg_store_relationships.py index 22542e15..64747654 100644 --- a/mcp_server/infrastructure/pg_store_relationships.py +++ b/mcp_server/infrastructure/pg_store_relationships.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - import psycopg + pass class PgRelationshipMixin(PgStoreHost): diff --git a/mcp_server/infrastructure/pg_store_rules.py b/mcp_server/infrastructure/pg_store_rules.py index 0cd8f625..ff753546 100644 --- a/mcp_server/infrastructure/pg_store_rules.py +++ b/mcp_server/infrastructure/pg_store_rules.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - import psycopg from psycopg import sql diff --git a/mcp_server/infrastructure/pg_store_wiki_citation_seed.py b/mcp_server/infrastructure/pg_store_wiki_citation_seed.py index 8d1235fd..d519f040 100644 --- a/mcp_server/infrastructure/pg_store_wiki_citation_seed.py +++ b/mcp_server/infrastructure/pg_store_wiki_citation_seed.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection # Measured 20 rows on the dev DB (2026-07-11); comfortably above any @@ -27,7 +27,7 @@ DEFAULT_SEED_SCAN_LIMIT = 5000 -def list_page_memory_seed_candidates(conn: Connection, limit: int) -> list[dict]: +def list_page_memory_seed_candidates(conn: StoreConnection, limit: int) -> list[dict]: """Every ``wiki.pages`` row with a non-null, FK-valid ``memory_id``. Precondition: none beyond a provisioned ``wiki`` schema. @@ -57,7 +57,7 @@ def list_page_memory_seed_candidates(conn: Connection, limit: int) -> list[dict] def list_existing_page_memory_citations( - conn: Connection, page_ids: list[int] + conn: StoreConnection, page_ids: list[int] ) -> set[tuple[int, int]]: """(page_id, memory_id) pairs already recorded in ``wiki.citations``. diff --git a/mcp_server/infrastructure/pg_store_wiki_claims.py b/mcp_server/infrastructure/pg_store_wiki_claims.py index b1f6977f..4ee52b2c 100644 --- a/mcp_server/infrastructure/pg_store_wiki_claims.py +++ b/mcp_server/infrastructure/pg_store_wiki_claims.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection import json @@ -20,7 +20,7 @@ from mcp_server.infrastructure.pg_store_wiki_common import _returning_id -def insert_claim_events(conn: Connection, claims: list[dict]) -> list[int]: +def insert_claim_events(conn: StoreConnection, claims: list[dict]) -> list[int]: """Bulk insert ClaimEvent rows. Returns the new ids in order. Each ``claims`` dict requires: ``text``, ``claim_type``. Optional: @@ -60,7 +60,7 @@ def insert_claim_events(conn: Connection, claims: list[dict]) -> list[int]: return out -def delete_claims_for_memory(conn: Connection, memory_id: int) -> int: +def delete_claims_for_memory(conn: StoreConnection, memory_id: int) -> int: """Remove all claim_events derived from a single memory. Used before re-extraction to keep the table clean of stale claims. @@ -70,7 +70,7 @@ def delete_claims_for_memory(conn: Connection, memory_id: int) -> int: return cur.rowcount -def get_claims_for_memory(conn: Connection, memory_id: int) -> list[dict]: +def get_claims_for_memory(conn: StoreConnection, memory_id: int) -> list[dict]: """Return all claim_events derived from a single memory.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working @@ -83,7 +83,7 @@ def get_claims_for_memory(conn: Connection, memory_id: int) -> list[dict]: def get_entities_by_memory( - conn: Connection, memory_ids: list[int] + conn: StoreConnection, memory_ids: list[int] ) -> dict[int, list[int]]: """Pre-fetch memory_id → list[entity_id] for a batch of memories.""" if not memory_ids: @@ -110,7 +110,7 @@ def get_entities_by_memory( _MIN_ENTITY_NAME_CHARS = 3 -def get_entity_name_index(conn: Connection, limit: int = 5000) -> dict[str, int]: +def get_entity_name_index(conn: StoreConnection, limit: int = 5000) -> dict[str, int]: """Return name → entity_id map for inline-mention matching. Limit caps the index size for in-memory matching against claim text. @@ -134,7 +134,7 @@ def get_entity_name_index(conn: Connection, limit: int = 5000) -> dict[str, int] def get_claims_by_entity( - conn: Connection, + conn: StoreConnection, entity_ids: list[int], exclude_claim_ids: list[int] | None = None, ) -> dict[int, list[dict]]: @@ -170,7 +170,7 @@ def get_claims_by_entity( def update_claim_entities( - conn: Connection, updates: list[tuple[int, list[int]]] + conn: StoreConnection, updates: list[tuple[int, list[int]]] ) -> int: """Bulk update wiki.claim_events.entity_ids. Returns rows updated. @@ -194,7 +194,9 @@ def update_claim_entities( return written -def update_claim_supersedes(conn: Connection, updates: list[tuple[int, int]]) -> int: +def update_claim_supersedes( + conn: StoreConnection, updates: list[tuple[int, int]] +) -> int: """Bulk update wiki.claim_events.supersedes. Returns rows updated. ``updates`` is [(new_claim_id, superseded_claim_id), ...]. diff --git a/mcp_server/infrastructure/pg_store_wiki_concepts.py b/mcp_server/infrastructure/pg_store_wiki_concepts.py index 9bc8fd84..db0c2d90 100644 --- a/mcp_server/infrastructure/pg_store_wiki_concepts.py +++ b/mcp_server/infrastructure/pg_store_wiki_concepts.py @@ -9,10 +9,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: - from psycopg import Connection + from typing_extensions import LiteralString + from mcp_server.infrastructure.db_types import StoreConnection import json @@ -21,7 +22,7 @@ def list_concepts( - conn: Connection, + conn: StoreConnection, *, status: str | None = None, limit: int = 200, @@ -41,7 +42,7 @@ def list_concepts( def get_concepts_by_entity_overlap( - conn: Connection, entity_ids: list[int] + conn: StoreConnection, entity_ids: list[int] ) -> list[dict]: """Return concepts whose entity_ids intersect the given list.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working @@ -56,7 +57,7 @@ def get_concepts_by_entity_overlap( return list(cur.fetchall()) -def insert_concept(conn: Connection, concept: dict[str, Any]) -> int: +def insert_concept(conn: StoreConnection, concept: dict[str, Any]) -> int: """Insert a new concept. Returns wiki.concepts.id.""" sql = """ INSERT INTO wiki.concepts ( @@ -112,7 +113,9 @@ def insert_concept(conn: Connection, concept: dict[str, Any]) -> int: ) -def update_concept(conn: Connection, concept_id: int, fields: dict[str, Any]) -> bool: +def update_concept( + conn: StoreConnection, concept_id: int, fields: dict[str, Any] +) -> bool: """Patch a concept row. Returns True if updated. Precondition: every key of ``fields`` is in ``_UPDATABLE_COLUMNS``; @@ -143,5 +146,5 @@ def update_concept(conn: Connection, concept_id: int, fields: dict[str, Any]) -> params.append(concept_id) sql = f"UPDATE wiki.concepts SET {', '.join(sets)} WHERE id = %s" # noqa: S608 — column names gated by the _UPDATABLE_COLUMNS allowlist (unknown keys refused); values are bound parameters (docs/ASSURANCE-CASE.md §5) with conn.cursor() as cur: - cur.execute(sql, params) + cur.execute(cast("LiteralString", sql), params) return cur.rowcount > 0 diff --git a/mcp_server/infrastructure/pg_store_wiki_domain.py b/mcp_server/infrastructure/pg_store_wiki_domain.py index 45fc98b6..e3ece5af 100644 --- a/mcp_server/infrastructure/pg_store_wiki_domain.py +++ b/mcp_server/infrastructure/pg_store_wiki_domain.py @@ -13,11 +13,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection def list_catchall_pages_with_sources( - conn: Connection, known_domains: list[str], limit: int + conn: StoreConnection, known_domains: list[str], limit: int ) -> list[dict]: """Pages whose domain isn't a registered project, with source paths. @@ -52,7 +52,7 @@ def list_catchall_pages_with_sources( return list(cur.fetchall()) -def update_page_domain(conn: Connection, page_id: int, domain: str) -> None: +def update_page_domain(conn: StoreConnection, page_id: int, domain: str) -> None: """Overwrite one page's ``domain`` column. Pre-condition: ``page_id`` refers to an existing ``wiki.pages`` row. diff --git a/mcp_server/infrastructure/pg_store_wiki_drafts.py b/mcp_server/infrastructure/pg_store_wiki_drafts.py index be481454..c8f73ecb 100644 --- a/mcp_server/infrastructure/pg_store_wiki_drafts.py +++ b/mcp_server/infrastructure/pg_store_wiki_drafts.py @@ -9,10 +9,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: - from psycopg import Connection + from typing_extensions import LiteralString + from mcp_server.infrastructure.db_types import StoreConnection import json @@ -20,7 +21,7 @@ from mcp_server.infrastructure.pg_store_wiki_common import _returning_id -def insert_draft(conn: Connection, draft: dict[str, Any]) -> int: +def insert_draft(conn: StoreConnection, draft: dict[str, Any]) -> int: """Insert a draft row. Returns the new wiki.drafts.id. Required: title, kind. Optional: concept_id, memory_id, lead, @@ -58,7 +59,7 @@ def insert_draft(conn: Connection, draft: dict[str, Any]) -> int: return _returning_id(cur.fetchone()) -def get_draft(conn: Connection, draft_id: int) -> dict | None: +def get_draft(conn: StoreConnection, draft_id: int) -> dict | None: from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working with conn.cursor(row_factory=dict_row) as cur: @@ -67,7 +68,7 @@ def get_draft(conn: Connection, draft_id: int) -> dict | None: def list_drafts( - conn: Connection, + conn: StoreConnection, *, status: str | None = None, kind: str | None = None, @@ -90,12 +91,12 @@ def list_drafts( """ # noqa: S608 — WHERE built from in-code literal fragments; values are bound parameters (docs/ASSURANCE-CASE.md §5) params.append(limit) with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql, params) + cur.execute(cast("LiteralString", sql), params) return list(cur.fetchall()) def update_draft( - conn: Connection, + conn: StoreConnection, draft_id: int, *, title: str | None = None, @@ -140,12 +141,12 @@ def update_draft( params.append(draft_id) sql = f"UPDATE wiki.drafts SET {', '.join(sets)} WHERE id = %s" # noqa: S608 — SET fragments are in-code literals appended per known field; values are bound parameters (docs/ASSURANCE-CASE.md §5) with conn.cursor() as cur: - cur.execute(sql, params) + cur.execute(cast("LiteralString", sql), params) return cur.rowcount > 0 def update_draft_status( - conn: Connection, + conn: StoreConnection, draft_id: int, *, status: str, @@ -169,7 +170,10 @@ def update_draft_status( def find_draft_for_source( - conn: Connection, *, memory_id: int | None = None, concept_id: int | None = None + conn: StoreConnection, + *, + memory_id: int | None = None, + concept_id: int | None = None, ) -> dict | None: """Return the most recent draft for a given source, or None.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working diff --git a/mcp_server/infrastructure/pg_store_wiki_links.py b/mcp_server/infrastructure/pg_store_wiki_links.py index 56359ade..98df39bb 100644 --- a/mcp_server/infrastructure/pg_store_wiki_links.py +++ b/mcp_server/infrastructure/pg_store_wiki_links.py @@ -12,14 +12,14 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection from mcp_server.infrastructure.pg_store_wiki_pages import get_page_by_slug def upsert_link( - conn: Connection, + conn: StoreConnection, src_page_id: int, dst_slug: str, link_kind: str = "see-also", @@ -45,14 +45,14 @@ def upsert_link( cur.execute(sql, (src_page_id, dst_slug, dst_page_id, link_kind)) -def delete_links_from(conn: Connection, src_page_id: int) -> int: +def delete_links_from(conn: StoreConnection, src_page_id: int) -> int: """Remove all outgoing links from a page (used before re-indexing).""" with conn.cursor() as cur: cur.execute("DELETE FROM wiki.links WHERE src_page_id = %s", (src_page_id,)) return cur.rowcount -def get_backlinks(conn: Connection, dst_page_id: int) -> list[dict]: +def get_backlinks(conn: StoreConnection, dst_page_id: int) -> list[dict]: """Return rows linking TO this page.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working @@ -69,7 +69,7 @@ def get_backlinks(conn: Connection, dst_page_id: int) -> list[dict]: return list(cur.fetchall()) -def resolve_unresolved_links(conn: Connection) -> int: +def resolve_unresolved_links(conn: StoreConnection) -> int: """Second-pass link resolution: fill in dst_page_id for links whose target didn't exist at insert time. Returns rows updated.""" with conn.cursor() as cur: diff --git a/mcp_server/infrastructure/pg_store_wiki_notes.py b/mcp_server/infrastructure/pg_store_wiki_notes.py index d9614708..5b1b8790 100644 --- a/mcp_server/infrastructure/pg_store_wiki_notes.py +++ b/mcp_server/infrastructure/pg_store_wiki_notes.py @@ -9,10 +9,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection import json @@ -21,7 +21,7 @@ def insert_citation( - conn: Connection, + conn: StoreConnection, page_id: int, session_id: str = "", domain: str = "", @@ -63,7 +63,7 @@ def insert_citation( def insert_memo( - conn: Connection, + conn: StoreConnection, subject_type: str, subject_id: int, decision: str, @@ -98,7 +98,9 @@ def insert_memo( return _returning_id(cur.fetchone()) -def list_uncited_deliberate_memories(conn: Connection, limit: int = 20) -> list[dict]: +def list_uncited_deliberate_memories( + conn: StoreConnection, limit: int = 20 +) -> list[dict]: """List active, deliberate, verifiably-important memories with zero wiki.citations rows — I6-D7's reverse loop ("orphelines délibérées"). @@ -147,7 +149,7 @@ def list_uncited_deliberate_memories(conn: Connection, limit: int = 20) -> list[ return list(cur.fetchall()) -def wiki_stats(conn: Connection) -> dict: +def wiki_stats(conn: StoreConnection) -> dict[str, Any]: """Counts across the wiki schema.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working @@ -169,4 +171,9 @@ def wiki_stats(conn: Connection) -> dict: (SELECT COUNT(*) FROM wiki.memos) AS memos """ ) - return cur.fetchone() + row = cur.fetchone() + if row is None: + # An aggregate SELECT always yields one row; None means the + # backend broke its contract — surface it, never return a fake. + raise RuntimeError("wiki_stats aggregate SELECT produced no row") + return dict(row) diff --git a/mcp_server/infrastructure/pg_store_wiki_pages.py b/mcp_server/infrastructure/pg_store_wiki_pages.py index 013c30af..e9e22fe7 100644 --- a/mcp_server/infrastructure/pg_store_wiki_pages.py +++ b/mcp_server/infrastructure/pg_store_wiki_pages.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection import json @@ -21,7 +21,7 @@ from mcp_server.infrastructure.pg_store_wiki_sources import upsert_page_sources -def upsert_page(conn: Connection, page: dict[str, Any]) -> tuple[int, bool]: +def upsert_page(conn: StoreConnection, page: dict[str, Any]) -> tuple[int, bool]: """Upsert a page row by rel_path. Returns ``(page_id, was_modified)`` where ``was_modified`` is True @@ -131,7 +131,7 @@ def upsert_page(conn: Connection, page: dict[str, Any]) -> tuple[int, bool]: return existing_id, False -def list_all_rel_paths(conn: Connection) -> list[str]: +def list_all_rel_paths(conn: StoreConnection) -> list[str]: """Return every rel_path currently stored in wiki.pages. Used by the migration reconciliation phase to compute which rows @@ -143,7 +143,7 @@ def list_all_rel_paths(conn: Connection) -> list[str]: return [r["rel_path"] if isinstance(r, dict) else r[0] for r in rows] -def delete_pages_by_rel_path(conn: Connection, rel_paths: list[str]) -> list[dict]: +def delete_pages_by_rel_path(conn: StoreConnection, rel_paths: list[str]) -> list[dict]: """Delete wiki.pages rows for the given rel_paths. Cascades to wiki.links (src_page_id ON DELETE CASCADE), wiki.page_sources @@ -169,7 +169,7 @@ def delete_pages_by_rel_path(conn: Connection, rel_paths: list[str]) -> list[dic return list(cur.fetchall()) -def get_page_by_slug(conn: Connection, slug: str) -> dict | None: +def get_page_by_slug(conn: StoreConnection, slug: str) -> dict | None: """Return a page row by slug, or None.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working @@ -178,7 +178,7 @@ def get_page_by_slug(conn: Connection, slug: str) -> dict | None: return cur.fetchone() -def get_page_by_rel_path(conn: Connection, rel_path: str) -> dict | None: +def get_page_by_rel_path(conn: StoreConnection, rel_path: str) -> dict | None: """Return a page row by rel_path, or None.""" from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working diff --git a/mcp_server/infrastructure/pg_store_wiki_sources.py b/mcp_server/infrastructure/pg_store_wiki_sources.py index 8794d954..fa288112 100644 --- a/mcp_server/infrastructure/pg_store_wiki_sources.py +++ b/mcp_server/infrastructure/pg_store_wiki_sources.py @@ -14,10 +14,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection -def list_pages_missing_source_link(conn: Connection, *, limit: int) -> list[dict]: +def list_pages_missing_source_link(conn: StoreConnection, *, limit: int) -> list[dict]: """Pages with no primary 'documents' source link (ADR-0051 STEP 3). Selects pages where ``documents_primary IS NULL`` (the fast-path @@ -52,10 +52,6 @@ def list_pages_missing_source_link(conn: Connection, *, limit: int) -> list[dict SourceEntry = str | tuple[str, str] | tuple[str, str, float] -# source: structural — a (path, source, confidence) SourceEntry tuple has -# three elements; shorter tuples fall back to the call-level defaults -_ENTRY_WITH_CONFIDENCE = 3 - def _entry_row( entry: SourceEntry, @@ -75,18 +71,17 @@ def _entry_row( provenance in one call, which a single call-level ``source`` cannot express). """ - if isinstance(entry, tuple): - path = entry[0] - entry_source = entry[1] if len(entry) > 1 else default_source - entry_confidence = ( - entry[2] if len(entry) >= _ENTRY_WITH_CONFIDENCE else default_confidence - ) - return page_id, path, link_kind, entry_confidence, entry_source - return page_id, entry, link_kind, default_confidence, default_source + match entry: + case (path, entry_source, entry_confidence): + return page_id, path, link_kind, entry_confidence, entry_source + case (path, entry_source): + return page_id, path, link_kind, default_confidence, entry_source + case _: + return page_id, entry, link_kind, default_confidence, default_source def upsert_page_sources( - conn: Connection, + conn: StoreConnection, page_id: int, documents: list[SourceEntry], *, diff --git a/mcp_server/infrastructure/pg_store_wiki_thermo.py b/mcp_server/infrastructure/pg_store_wiki_thermo.py index bc9b523e..2a813d29 100644 --- a/mcp_server/infrastructure/pg_store_wiki_thermo.py +++ b/mcp_server/infrastructure/pg_store_wiki_thermo.py @@ -12,11 +12,11 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from psycopg import Connection + from mcp_server.infrastructure.db_types import StoreConnection def list_pages_for_decay( - conn: Connection, + conn: StoreConnection, *, limit: int = 5000, include_archived: bool = False, @@ -43,7 +43,7 @@ def list_pages_for_decay( return list(cur.fetchall()) -def apply_thermo_decisions(conn: Connection, decisions: list[Any]) -> int: +def apply_thermo_decisions(conn: StoreConnection, decisions: list[Any]) -> int: """Bulk apply HeatDecision rows. Returns count of pages written. Idempotent — UPDATE only fires when at least one of (heat, @@ -82,7 +82,7 @@ def apply_thermo_decisions(conn: Connection, decisions: list[Any]) -> int: return written -def apply_staleness_decisions(conn: Connection, decisions: list[Any]) -> int: +def apply_staleness_decisions(conn: StoreConnection, decisions: list[Any]) -> int: """Bulk apply StalenessDecision rows. Returns transitions written.""" if not decisions: return 0 @@ -100,7 +100,7 @@ def apply_staleness_decisions(conn: Connection, decisions: list[Any]) -> int: def get_claim_file_refs_for_pages( - conn: Connection, page_ids: list[int] + conn: StoreConnection, page_ids: list[int] ) -> dict[int, list[str]]: """For each page, return the file paths cited by its source memory's claims. diff --git a/mcp_server/infrastructure/pipeline_install_lock.py b/mcp_server/infrastructure/pipeline_install_lock.py index 63ad5629..4946cc95 100644 --- a/mcp_server/infrastructure/pipeline_install_lock.py +++ b/mcp_server/infrastructure/pipeline_install_lock.py @@ -20,10 +20,11 @@ from __future__ import annotations import os +import sys from contextlib import contextmanager from typing import Iterator -from mcp_server.shared.platform import IS_WINDOWS, home_dir +from mcp_server.shared.platform import home_dir _LOCK_FILE = home_dir() / ".claude" / "methodology" / ".install.lock" @@ -59,7 +60,8 @@ def install_lock() -> Iterator[None]: pass -if IS_WINDOWS: +if sys.platform == "win32": # == shared.platform.IS_WINDOWS; literal so the checker + # resolves the msvcrt/fcntl split per-platform instead of flagging both. import msvcrt def _acquire(fd: int) -> None: diff --git a/mcp_server/infrastructure/pipeline_installer.py b/mcp_server/infrastructure/pipeline_installer.py index 256794f1..a1ebe557 100644 --- a/mcp_server/infrastructure/pipeline_installer.py +++ b/mcp_server/infrastructure/pipeline_installer.py @@ -119,9 +119,14 @@ def _install_locked(force_rebuild: bool, git_url: Optional[str]) -> dict: cargo = resolve_cargo() if not cargo: rust_result = install_rust_toolchain() - if rust_result.get("action") in {"rust_installed", "rust_already_present"}: - cargo = rust_result.get("cargo") - else: + installed = rust_result.get("action") in { + "rust_installed", + "rust_already_present", + } + cargo = rust_result.get("cargo") + # A success action with no usable cargo path would previously flow a + # None into the build command; refuse it as missing_toolchain instead. + if not installed or not isinstance(cargo, str) or not cargo: return { "action": "missing_toolchain", "missing": ["cargo"], diff --git a/mcp_server/infrastructure/sqlite_compat.py b/mcp_server/infrastructure/sqlite_compat.py index d6c87e65..d185ce2d 100644 --- a/mcp_server/infrastructure/sqlite_compat.py +++ b/mcp_server/infrastructure/sqlite_compat.py @@ -174,7 +174,7 @@ class _CompatCursor: def __init__( self, cursor: sqlite3.Cursor, - lastrowid: int, + lastrowid: int | None, *, had_returning: bool = False, ) -> None: @@ -230,6 +230,22 @@ def execute(self, sql: str, params: Any = None) -> "_CompatExecutingCursor": self.rowcount = self._cursor.rowcount return self + def executemany(self, sql: str, params_seq: Any) -> "_CompatExecutingCursor": + """psycopg-parity executemany with SQL translation. + + Shared query modules (e.g. ``pg_store_wiki_sources.upsert_page_sources``) + batch their inserts through ``cur.executemany``; without this method the + whole call chain raised ``AttributeError`` on the SQLite backend — the + same silent-degradation class as issue #206. RETURNING is not supported + here (psycopg's ``executemany`` does not return rows either). + """ + translated = _translate_sql(sql) + self._had_returning = False + self._cursor.executemany(translated, params_seq) + self.lastrowid = self._cursor.lastrowid + self.rowcount = self._cursor.rowcount + return self + def fetchone(self) -> dict[str, Any] | None: row = self._cursor.fetchone() if row is None: diff --git a/mcp_server/infrastructure/sqlite_store.py b/mcp_server/infrastructure/sqlite_store.py index 530ca4b7..b661dded 100644 --- a/mcp_server/infrastructure/sqlite_store.py +++ b/mcp_server/infrastructure/sqlite_store.py @@ -415,6 +415,10 @@ def _insert_memory_rows(self, data: dict[str, Any]) -> int: ), ) memory_id = cur.lastrowid + if memory_id is None: + # An INSERT always assigns a rowid; None means the statement did + # not execute as an INSERT — a broken contract, not a data state. + raise sqlite3.ProgrammingError("memory INSERT produced no lastrowid") self._conn.execute( "INSERT INTO memories_fts(rowid, content) VALUES (?, ?)", (memory_id, _fts_augment(content)), @@ -437,7 +441,7 @@ def _insert_memory_rows(self, data: dict[str, Any]) -> int: "INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)", (memory_id, vec.tobytes()), ) - return memory_id # type: ignore[return-value] + return memory_id def insert_memory(self, data: dict[str, Any]) -> int: """Insert a memory into memories + FTS5 + vec tables.""" @@ -584,7 +588,7 @@ def get_homeostatic_factor(self, domain: str, write_class: str = "auto") -> floa if row is None: return 1.0 try: - return float(row["factor"] if hasattr(row, "__getitem__") else row[0]) + return float(row["factor"]) except (KeyError, TypeError, IndexError): return 1.0 diff --git a/mcp_server/infrastructure/sqlite_store_entities.py b/mcp_server/infrastructure/sqlite_store_entities.py index 330ba0c5..74882e3d 100644 --- a/mcp_server/infrastructure/sqlite_store_entities.py +++ b/mcp_server/infrastructure/sqlite_store_entities.py @@ -2,6 +2,8 @@ from __future__ import annotations +import sqlite3 + from typing import Any from mcp_server.infrastructure.sqlite_compat import PsycopgCompatConnection from mcp_server.shared.code_tokenize import expand_fts_query @@ -11,6 +13,7 @@ class SqliteEntityMixin: """Entity persistence operations on SQLite.""" _conn: PsycopgCompatConnection + _raw_conn: sqlite3.Connection def _normalize_memory_row(self, row: dict) -> dict: """Provided by SqliteMemoryStore.""" diff --git a/mcp_server/infrastructure/sqlite_store_mood.py b/mcp_server/infrastructure/sqlite_store_mood.py index 794aa034..012db5bd 100644 --- a/mcp_server/infrastructure/sqlite_store_mood.py +++ b/mcp_server/infrastructure/sqlite_store_mood.py @@ -58,7 +58,7 @@ def get_user_mood(self, user_id: str = "default") -> float | None: if row is None: return None try: - val = row["valence"] if hasattr(row, "__getitem__") else row[0] + val = row["valence"] return float(val) except (KeyError, TypeError, ValueError, IndexError): return None @@ -86,10 +86,7 @@ def get_user_mood_state(self, user_id: str = "default") -> dict[str, float] | No if row is None: return None try: - if hasattr(row, "__getitem__"): - valence, arousal = row["valence"], row["arousal"] - else: - valence, arousal = row[0], row[1] + valence, arousal = row["valence"], row["arousal"] return {"valence": float(valence), "arousal": float(arousal)} except (KeyError, TypeError, ValueError, IndexError): return None @@ -164,7 +161,7 @@ def get_embeddings_for_memories(self, memory_ids: list[int]) -> dict[int, bytes] ).fetchone() if row is None: continue - raw = row["embedding"] if hasattr(row, "__getitem__") else row[0] + raw = row["embedding"] if raw is not None: result[int(mid)] = bytes(raw) except sqlite3.Error: diff --git a/mcp_server/infrastructure/sqlite_store_receipts.py b/mcp_server/infrastructure/sqlite_store_receipts.py index af754f51..19884cc7 100644 --- a/mcp_server/infrastructure/sqlite_store_receipts.py +++ b/mcp_server/infrastructure/sqlite_store_receipts.py @@ -8,10 +8,19 @@ from __future__ import annotations +import sqlite3 +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp_server.infrastructure.sqlite_compat import PsycopgCompatConnection + class SqliteReceiptsMixin: """Append-only injection receipts (blame path T1).""" + _conn: PsycopgCompatConnection + _raw_conn: sqlite3.Connection + def insert_injection_receipt( self, channel: str, @@ -25,7 +34,9 @@ def insert_injection_receipt( "INSERT INTO injection_receipts (session_id, channel) VALUES (?, ?)", (session_id, channel), ) - receipt_id = int(cur.lastrowid) + receipt_id = cur.lastrowid + if receipt_id is None: + raise sqlite3.ProgrammingError("receipt INSERT produced no lastrowid") self._raw_conn.executemany( "INSERT INTO injection_receipt_items" " (receipt_id, memory_id, rank, score) VALUES (?, ?, ?, ?)", diff --git a/mcp_server/infrastructure/sqlite_store_relationships.py b/mcp_server/infrastructure/sqlite_store_relationships.py index 1b250a0e..a7019a40 100644 --- a/mcp_server/infrastructure/sqlite_store_relationships.py +++ b/mcp_server/infrastructure/sqlite_store_relationships.py @@ -2,6 +2,8 @@ from __future__ import annotations +import sqlite3 + from mcp_server.infrastructure.sqlite_compat import PsycopgCompatConnection from typing import Any @@ -10,6 +12,7 @@ class SqliteRelationshipMixin: """Relationship persistence operations on SQLite.""" _conn: PsycopgCompatConnection + _raw_conn: sqlite3.Connection def update_relationships_weight_batch( self, updates: list[tuple[int, float]] diff --git a/mcp_server/infrastructure/sqlite_store_search.py b/mcp_server/infrastructure/sqlite_store_search.py index b7c1758b..510e62c4 100644 --- a/mcp_server/infrastructure/sqlite_store_search.py +++ b/mcp_server/infrastructure/sqlite_store_search.py @@ -543,9 +543,7 @@ def _fetch_embedding_bytes(self, memory_id: int) -> bytes | None: ).fetchone() if vec_row is None: return None - raw = ( - vec_row["embedding"] if hasattr(vec_row, "__getitem__") else vec_row[0] - ) + raw = vec_row["embedding"] if raw is None: return None # sqlite-vec returns a buffer/memoryview; convert to bytes. diff --git a/mcp_server/infrastructure/staging_resolve_sink.py b/mcp_server/infrastructure/staging_resolve_sink.py index 0c162f4d..3ae9cfc5 100644 --- a/mcp_server/infrastructure/staging_resolve_sink.py +++ b/mcp_server/infrastructure/staging_resolve_sink.py @@ -31,6 +31,7 @@ ) if TYPE_CHECKING: + from typing_extensions import LiteralString import psycopg @@ -46,15 +47,15 @@ def __init__( self, acquire: ConnectAcquire, *, - stage_ddl: str, - copy_sql: str, - resolve_sql: str, + stage_ddl: LiteralString, + copy_sql: LiteralString, + resolve_sql: LiteralString, row_adapter: RowAdapter | None = None, ) -> None: super().__init__(acquire, row_adapter) - self._stage_ddl = stage_ddl - self._copy_sql = copy_sql - self._resolve_sql = resolve_sql + self._stage_ddl: LiteralString = stage_ddl + self._copy_sql: LiteralString = copy_sql + self._resolve_sql: LiteralString = resolve_sql def _on_connect(self, conn: psycopg.Connection) -> None: """Declare the session-temp staging table once per borrowed connection. From 7277ff7c992fba081c4787870b5452e9b7c8827a Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 15:15:55 +0200 Subject: [PATCH 3/8] =?UTF-8?q?fix(types):=20burn=20the=20pyright=20backlo?= =?UTF-8?q?g=20to=20zero=20=E2=80=94=20SQLite=20store=20parity,=20honest?= =?UTF-8?q?=20report=20types,=20capability=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store parity (the largest latent-bug class this family surfaced — every site below raised AttributeError on the SQLite backend, swallowed by broad stage boundaries into silent degradation, the FlashRank shape): - SqliteMemoryStore gains acquire_interactive/acquire_batch (yielding the store's single WAL connection — the exact shape PgMemoryStore's POOL_DISABLED path yields), _execute (translating passthrough), search_newer_neighbors (numpy cosine parity of the pgvector query), update_forgetting_pressure_accum, get_memories_by_tag (json_each containment), iter_memories_for_decay (single-chunk parity of the PG kill-switch path), find_co_accessed_pairs (same memory_entities self-join). Callers: anchor, get_rules, backfill, codebase_analyze, consolidation (sleep/forgetting/homeostatic/memify/cascade), ingest_findings, remember_helpers. - PG-only capabilities became explicit runtime-checkable protocols with named degraded modes: procedural skills (recall_skills), fallback re-embed worklist (embedding_upgrade). write_gate/pg_recall keep hasattr guards (their observability tests pin MagicMock seams, which 3.12+ protocol isinstance — getattr_static — correctly rejects). Honest annotations replacing checked-off lies (each switched type checking back on for every caller): - EmbeddingEngine.encode returns bytes, not list[float] (compression); fractal scoring takes bytes end-to-end (recall_hierarchical); compute_centroid accepts the None-bearing lists it already handles; emergence/interference reports carry labels, not float-only dicts; update_style_ema pins its dict contract ((None, None) returned None). - MemoryStore construction in hook processes goes through get_shared_store (the factory the module itself mandates); the TYPE_CHECKING union alias is no longer called. - psycopg connect sites parameterize the class (Connection[DictRow].connect) so dict rows type through; hook tests retargeted to the consumer's actual call path (#238 seam rule). - safe_handler accepts Awaitable-returning callables (what HandlerFn is); max(d, key=d.get) rewritten with lambdas (the None-default overload poisons the key type); frontmatter object-reads narrowed at each seam (wiki_migrate/schema_loader/sync/identity/draft_compiler); the synthesized fake-Node hack in ast_extractors replaced by a child-list dispatch helper (§7.2). Latent bugs fixed beyond the store parity: - sparse_dictionary.encode_session: a direction-less feature now fails loudly with its index/label instead of a bare TypeError inside omp (positional alignment forbids skipping). - get_causal_chain: an absent start entity produced reason=None; now always a named reason. - forgetting: a row without created_at is stated to have no newer neighbors (was an implicit SQL NULL-comparison no-op). pyright (standard, 1.1.410): 125 -> 0 on mcp_server/. Refs #197 Co-Authored-By: Claude Fable 5 --- mcp_server/core/ablation.py | 2 +- mcp_server/core/abstention_gate.py | 4 +- mcp_server/core/ast_extractors.py | 14 ++- mcp_server/core/claim_extractor.py | 8 +- mcp_server/core/claim_resolver.py | 7 +- mcp_server/core/codebase_communities.py | 9 +- mcp_server/core/context_assembly/coverage.py | 5 +- .../core/context_assembly/stage_detector.py | 2 +- mcp_server/core/domain_detector.py | 4 +- mcp_server/core/draft_compiler.py | 2 +- mcp_server/core/emergence_metrics.py | 6 +- mcp_server/core/emergence_tracker.py | 2 +- mcp_server/core/emotional_tagging.py | 2 +- mcp_server/core/fractal_clustering.py | 4 +- mcp_server/core/interference.py | 2 +- mcp_server/core/persona_vector.py | 8 +- mcp_server/core/pg_recall.py | 7 +- mcp_server/core/query_intent.py | 2 +- mcp_server/core/sparse_dictionary.py | 11 +- mcp_server/core/style_classifier_ema.py | 9 +- mcp_server/core/wiki_identity.py | 4 +- mcp_server/core/wiki_schema_loader.py | 19 ++-- mcp_server/core/wiki_sync.py | 3 +- mcp_server/core/write_gate.py | 12 +-- mcp_server/core/write_post_store.py | 5 +- mcp_server/handlers/codebase_analyze.py | 2 +- .../handlers/consolidation/compression.py | 4 +- .../consolidation/embedding_upgrade.py | 22 +++- .../handlers/consolidation/forgetting.py | 11 +- mcp_server/handlers/consolidation/memify.py | 2 +- mcp_server/handlers/get_causal_chain.py | 2 +- mcp_server/handlers/ingest_helpers.py | 7 +- mcp_server/handlers/query_methodology.py | 2 +- mcp_server/handlers/recall_hierarchical.py | 2 +- mcp_server/handlers/recall_skills.py | 19 ++++ mcp_server/handlers/remember_helpers.py | 2 +- mcp_server/handlers/seed_project_stages.py | 2 +- mcp_server/handlers/wiki_migrate.py | 12 ++- mcp_server/handlers/wiki_resolve.py | 4 +- mcp_server/handlers/wiki_view.py | 20 +++- mcp_server/hooks/agent_briefing.py | 6 +- mcp_server/hooks/auto_recall.py | 6 +- mcp_server/hooks/compaction_checkpoint.py | 4 +- mcp_server/hooks/pipeline_impact_bump.py | 13 ++- mcp_server/hooks/post_tool_capture.py | 4 +- mcp_server/hooks/session_start.py | 6 +- .../infrastructure/pg_store_wiki_sources.py | 4 +- mcp_server/infrastructure/sqlite_store.py | 100 ++++++++++++++++++ .../infrastructure/sqlite_store_queries.py | 60 ++++++++++- mcp_server/migrate.py | 4 +- mcp_server/shared/json_native.py | 6 +- mcp_server/shared/wiki_source_paths.py | 4 +- mcp_server/tool_error_handler.py | 6 +- tests_py/hooks/test_agent_briefing.py | 6 +- tests_py/hooks/test_ble001_sweep_hooks.py | 2 +- 55 files changed, 387 insertions(+), 110 deletions(-) diff --git a/mcp_server/core/ablation.py b/mcp_server/core/ablation.py index 5c8f2560..1eb023af 100644 --- a/mcp_server/core/ablation.py +++ b/mcp_server/core/ablation.py @@ -38,7 +38,7 @@ def is_mechanism_disabled(mechanism: "Mechanism | str") -> bool: Accepts either a Mechanism enum (uses .name -> e.g. "OSCILLATORY_CLOCK") or a string (upper-cased, hyphens normalized). """ - if hasattr(mechanism, "name"): + if isinstance(mechanism, Mechanism): name = mechanism.name else: name = str(mechanism).upper().replace("-", "_") diff --git a/mcp_server/core/abstention_gate.py b/mcp_server/core/abstention_gate.py index 5645f646..900ebf14 100644 --- a/mcp_server/core/abstention_gate.py +++ b/mcp_server/core/abstention_gate.py @@ -48,7 +48,9 @@ def _get_classifier() -> Any: _load_attempted = True try: # Lazy import — package is optional - from cortex_beam_abstain import AbstentionClassifier # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode + from cortex_beam_abstain import ( # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode # pyright: ignore[reportMissingImports] — optional package, not installed in the type-check env; the except ImportError arm IS the contract + AbstentionClassifier, + ) cache = Path.home() / ".cache" / "cortex-abstention" / "model.onnx" if cache.exists(): diff --git a/mcp_server/core/ast_extractors.py b/mcp_server/core/ast_extractors.py index 59e8f42a..558a8c76 100644 --- a/mcp_server/core/ast_extractors.py +++ b/mcp_server/core/ast_extractors.py @@ -69,8 +69,17 @@ def extract_python_definitions( parent_class: str = "", ) -> list[SymbolDef]: """Extract Python def/class with class-method binding.""" + return _extract_python_children(root.children, source, parent_class) + + +def _extract_python_children( + children: list[Node], + source: bytes, + parent_class: str, +) -> list[SymbolDef]: + """Dispatch definition extraction over a sequence of sibling nodes.""" defs: list[SymbolDef] = [] - for node in root.children: + for node in children: if node.type == "function_definition": _extract_python_func(node, source, defs, parent_class) elif node.type == "decorated_definition": @@ -105,8 +114,7 @@ def _extract_python_decorated( """Extract definitions from decorated blocks.""" for child in node.children: if child.type in ("function_definition", "class_definition"): - fake = type("N", (), {"children": [child]})() - defs.extend(extract_python_definitions(fake, source, parent)) + defs.extend(_extract_python_children([child], source, parent)) def _extract_python_class( diff --git a/mcp_server/core/claim_extractor.py b/mcp_server/core/claim_extractor.py index 3c955f5c..37b38f4f 100644 --- a/mcp_server/core/claim_extractor.py +++ b/mcp_server/core/claim_extractor.py @@ -17,6 +17,8 @@ from __future__ import annotations +from typing import Literal + import re from dataclasses import dataclass @@ -192,7 +194,11 @@ def _extract_evidence(content: str) -> list[EvidenceRef]: refs: list[EvidenceRef] = [] seen: set[tuple[str, str]] = set() - def _add(kind: str, target: str, context: str | None = None) -> None: + def _add( + kind: Literal["file", "commit", "paper", "memory", "claim", "benchmark", "url"], + target: str, + context: str | None = None, + ) -> None: target = target.strip().rstrip(".,;:") key = (kind, target) if not target or key in seen: diff --git a/mcp_server/core/claim_resolver.py b/mcp_server/core/claim_resolver.py index 47aa9e45..1ba15285 100644 --- a/mcp_server/core/claim_resolver.py +++ b/mcp_server/core/claim_resolver.py @@ -98,7 +98,12 @@ def plan_entity_links( plans: list[EntityLinkPlan] = [] name_map = {k.lower(): v for k, v in (entity_name_to_id or {}).items()} for c in claims: - ids = set(entities_by_memory.get(c.get("memory_id"), [])) + memory_id = c.get("memory_id") + ids = ( + set(entities_by_memory.get(memory_id, [])) + if memory_id is not None + else set() + ) if name_map: text = (c.get("text") or "").lower() # Word-bounded substring — cheap pre-filter, no false positives diff --git a/mcp_server/core/codebase_communities.py b/mcp_server/core/codebase_communities.py index 42bb210c..a16a48c5 100644 --- a/mcp_server/core/codebase_communities.py +++ b/mcp_server/core/codebase_communities.py @@ -23,6 +23,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import networkx as nx + # source: structural — a graph needs at least two nodes before community # partitioning or centrality ranking is meaningful _MIN_NODES_FOR_GRAPH_ANALYSIS = 2 @@ -33,7 +38,7 @@ def _build_dependency_graph( file_edges: list[tuple[str, str]], call_edges: list[tuple[str, str, str]], -) -> object: +) -> nx.Graph: """Build a weighted networkx graph from file and call edges.""" import networkx as nx # noqa: PLC0415 — optional dependency ([codebase] extra); imported where used so environments without it keep working @@ -48,7 +53,7 @@ def _build_dependency_graph( return g -def _leiden_partition(g: object) -> dict[str, int] | None: +def _leiden_partition(g: nx.Graph) -> dict[str, int] | None: """Partition g with the Leiden algorithm, or None if deps are absent. Implements Traag et al. (2019) via the authors' reference library diff --git a/mcp_server/core/context_assembly/coverage.py b/mcp_server/core/context_assembly/coverage.py index a8a64f8b..4db61125 100644 --- a/mcp_server/core/context_assembly/coverage.py +++ b/mcp_server/core/context_assembly/coverage.py @@ -118,8 +118,9 @@ def submodular_select( if best_i < 0: break # No candidate fits budget selected.append(best_i) - if embeddings[best_i] is not None: - selected_embs.append(embeddings[best_i]) + best_emb = embeddings[best_i] + if best_emb is not None: + selected_embs.append(best_emb) used_tokens += token_counts[best_i] # Return in original ranking order for readability diff --git a/mcp_server/core/context_assembly/stage_detector.py b/mcp_server/core/context_assembly/stage_detector.py index cefe4e12..21372a36 100644 --- a/mcp_server/core/context_assembly/stage_detector.py +++ b/mcp_server/core/context_assembly/stage_detector.py @@ -166,7 +166,7 @@ def _parse_ts(value: Any) -> datetime | None: day = int(parts[1]) year = int(parts[2]) month_abbrs = { - m.lower(): i for i, m in enumerate(calendar.month_name) if m + calendar.month_name[i].lower(): i for i in range(1, 13) } month_num = month_abbrs.get(month_name.lower()) if month_num: diff --git a/mcp_server/core/domain_detector.py b/mcp_server/core/domain_detector.py index 5a884f3f..f96049cf 100644 --- a/mcp_server/core/domain_detector.py +++ b/mcp_server/core/domain_detector.py @@ -176,9 +176,7 @@ def detect_domain( project = context.get("project") first_message = context.get("first_message") - has_domains = profiles and profiles.get("domains") and len(profiles["domains"]) > 0 - - if not has_domains: + if not profiles or not profiles.get("domains"): return _build_cold_start_result() project_id = project or cwd_to_project_id(cwd) diff --git a/mcp_server/core/draft_compiler.py b/mcp_server/core/draft_compiler.py index 219f9107..d75d645a 100644 --- a/mcp_server/core/draft_compiler.py +++ b/mcp_server/core/draft_compiler.py @@ -190,7 +190,7 @@ def compile_draft( body = s.get("body") if isinstance(s, dict) else getattr(s, "body", "") if not heading: continue - body_parts.append(_section_md(heading, body)) + body_parts.append(_section_md(str(heading), str(body or ""))) if backlinks: body_parts.append("## See also\n") diff --git a/mcp_server/core/emergence_metrics.py b/mcp_server/core/emergence_metrics.py index d0165d15..160e261a 100644 --- a/mcp_server/core/emergence_metrics.py +++ b/mcp_server/core/emergence_metrics.py @@ -99,7 +99,7 @@ def _ols_sums( def _fit_log_linear( log_heats: list[tuple[float, float]], -) -> dict[str, float]: +) -> dict[str, float | str]: """Fit log-linear regression: log(heat) = log(a) - b * age via OLS. Returns dict with curve_type, r_squared, half_life_hours, @@ -169,7 +169,7 @@ def _fit_quality_for(r_squared: float) -> str: def compute_forgetting_curve( memories_by_age: list[tuple[float, float]], -) -> dict[str, float]: +) -> dict[str, float | str]: """Fit a forgetting curve to memory age vs heat data. Fits a single EXPONENTIAL R(t) = a · exp(-b · t) by OLS of ln(heat) on @@ -196,7 +196,7 @@ def compute_forgetting_curve( def _forgetting_from_bin_means( bin_means: list[tuple[float, float]], n_points: int -) -> dict[str, float]: +) -> dict[str, float | str]: """Fit the curve from already-binned (center, mean_heat) data. Shared by ``compute_forgetting_curve`` (list path) and the streaming diff --git a/mcp_server/core/emergence_tracker.py b/mcp_server/core/emergence_tracker.py index 04f99c11..ed4fd18a 100644 --- a/mcp_server/core/emergence_tracker.py +++ b/mcp_server/core/emergence_tracker.py @@ -140,7 +140,7 @@ def _consolidated_fraction(mems: list[dict]) -> float: def compute_schema_acceleration_metric( schema_consistent_memories: list[dict], schema_inconsistent_memories: list[dict], -) -> dict[str, float]: +) -> dict[str, float | str]: """Measure schema acceleration: do schema-consistent memories consolidate faster? Compares average hours to reach CONSOLIDATED stage. diff --git a/mcp_server/core/emotional_tagging.py b/mcp_server/core/emotional_tagging.py index 0e8ed6b1..bca45936 100644 --- a/mcp_server/core/emotional_tagging.py +++ b/mcp_server/core/emotional_tagging.py @@ -298,7 +298,7 @@ def tag_memory_emotions(content: str) -> dict[str, Any]: is_emotional = arousal > _EMOTIONAL_AROUSAL_THRESHOLD # Dominant emotion - dominant = max(emotions, key=emotions.get) if is_emotional else "neutral" + dominant = max(emotions, key=lambda k: emotions[k]) if is_emotional else "neutral" return { "emotions": emotions, diff --git a/mcp_server/core/fractal_clustering.py b/mcp_server/core/fractal_clustering.py index e1dce9ef..6f8e9304 100644 --- a/mcp_server/core/fractal_clustering.py +++ b/mcp_server/core/fractal_clustering.py @@ -96,7 +96,7 @@ def agglomerative_cluster( def compute_centroid( - embeddings: list[bytes], + embeddings: list[bytes | None], dim: int, ) -> bytes | None: """Compute mean centroid of byte-encoded float32 embeddings. @@ -210,4 +210,4 @@ def _find_dominant_directory( d = mem.get("directory_context") or mem.get("domain") or "global" dirs[d] = dirs.get(d, 0) + 1 - return max(dirs, key=dirs.get) if dirs else "global" + return max(dirs, key=lambda k: dirs[k]) if dirs else "global" diff --git a/mcp_server/core/interference.py b/mcp_server/core/interference.py index 3948fc85..0e3f8eb6 100644 --- a/mcp_server/core/interference.py +++ b/mcp_server/core/interference.py @@ -314,7 +314,7 @@ def compute_domain_interference_pressure( *, threshold: float = _INTERFERENCE_THRESHOLD, sample_limit: int = 100, -) -> dict[str, float]: +) -> dict[str, float | str]: """Compute aggregate interference metrics for a domain. Args: diff --git a/mcp_server/core/persona_vector.py b/mcp_server/core/persona_vector.py index 03b9ba40..4a390099 100644 --- a/mcp_server/core/persona_vector.py +++ b/mcp_server/core/persona_vector.py @@ -79,10 +79,10 @@ def build_persona_vector(profile: dict) -> dict[str, float]: ss = profile.get("sessionShape") or {} tp = profile.get("toolPreferences") or {} - result = { - "activeReflective": mc.get("activeReflective") or 0, - "sensingIntuitive": mc.get("sensingIntuitive") or 0, - "sequentialGlobal": mc.get("sequentialGlobal") or 0, + result: dict[str, float] = { + "activeReflective": float(mc.get("activeReflective") or 0), + "sensingIntuitive": float(mc.get("sensingIntuitive") or 0), + "sequentialGlobal": float(mc.get("sequentialGlobal") or 0), } result.update(_compute_behavioral_dims(ss, tp)) return result diff --git a/mcp_server/core/pg_recall.py b/mcp_server/core/pg_recall.py index 29d5a705..eaf34448 100644 --- a/mcp_server/core/pg_recall.py +++ b/mcp_server/core/pg_recall.py @@ -77,13 +77,10 @@ def _get_active_goal(store: Any) -> Any: fails. Per the source-discipline rule we never fabricate a goal signal. """ - if store is None: - return goal_maintenance.EMPTY_GOAL - reader = getattr(store, "get_active_prospective_memories", None) - if not callable(reader): + if store is None or not hasattr(store, "get_active_prospective_memories"): return goal_maintenance.EMPTY_GOAL try: - triggers = reader() + triggers = store.get_active_prospective_memories() return goal_maintenance.build_goal_from_triggers(triggers) except Exception as exc: # noqa: BLE001 — non-load-bearing; absence is fine silent_failure.note("pg_recall.active_goal", exc) diff --git a/mcp_server/core/query_intent.py b/mcp_server/core/query_intent.py index 3a37e486..710e0332 100644 --- a/mcp_server/core/query_intent.py +++ b/mcp_server/core/query_intent.py @@ -198,7 +198,7 @@ def classify_query_intent(query: str) -> dict[str, Any]: if max_score == 0: primary = QueryIntent.GENERAL else: - primary = max(scores, key=scores.get) + primary = max(scores, key=lambda k: scores[k]) weights = compute_retrieval_weights(primary, scores) diff --git a/mcp_server/core/sparse_dictionary.py b/mcp_server/core/sparse_dictionary.py index c8835b6a..6e2945e3 100644 --- a/mcp_server/core/sparse_dictionary.py +++ b/mcp_server/core/sparse_dictionary.py @@ -265,7 +265,16 @@ def label_feature(direction: list[float], index: int) -> Feature: def encode_session(conversation: dict, dictionary: FeatureDictionary) -> EncodedSession: """Encode a single conversation against a learned dictionary.""" activation = extract_session_activation(conversation) - atoms = [f.direction for f in dictionary.features] + atoms: list[list[float]] = [] + for f in dictionary.features: + if f.direction is None: + # Positional alignment between atoms and result["indices"] forbids + # skipping; a dictionary missing directions is unencodable input. + raise ValueError( + f"feature {f.index} ({f.label!r}) has no direction; " + "dictionary cannot encode sessions" + ) + atoms.append(f.direction) result = omp(activation, atoms, dictionary.sparsity) weights: dict[str, float] = {} diff --git a/mcp_server/core/style_classifier_ema.py b/mcp_server/core/style_classifier_ema.py index 72b9265d..1a509b54 100644 --- a/mcp_server/core/style_classifier_ema.py +++ b/mcp_server/core/style_classifier_ema.py @@ -48,8 +48,11 @@ def _select_categorical( old_style: dict, new_observation: dict, alpha: float, -) -> tuple[str, str, str]: - """Select categorical dimensions based on alpha threshold.""" +) -> tuple[str | None, str | None, str | None]: + """Select categorical dimensions based on alpha threshold. + + A dimension absent from both inputs stays None. + """ adopt_new = alpha >= _CATEGORICAL_ADOPT_ALPHA primary = new_observation if adopt_new else old_style fallback = old_style if adopt_new else new_observation @@ -67,7 +70,7 @@ def update_style_ema( ) -> dict[str, Any]: """Blend an existing style with a new observation using EMA.""" if not old_style: - return new_observation + return new_observation or {} if not new_observation: return old_style diff --git a/mcp_server/core/wiki_identity.py b/mcp_server/core/wiki_identity.py index 8954f019..df891d31 100644 --- a/mcp_server/core/wiki_identity.py +++ b/mcp_server/core/wiki_identity.py @@ -124,11 +124,11 @@ def extract_memory_id(frontmatter: dict[str, object]) -> int | None: that want to round-trip the field without re-parsing. """ raw = frontmatter.get("memory_id") - if raw is None: + if not isinstance(raw, (int, float, str)): return None try: return int(raw) - except (TypeError, ValueError): + except ValueError: return None diff --git a/mcp_server/core/wiki_schema_loader.py b/mcp_server/core/wiki_schema_loader.py index 1bdd7f35..499f0da7 100644 --- a/mcp_server/core/wiki_schema_loader.py +++ b/mcp_server/core/wiki_schema_loader.py @@ -111,16 +111,23 @@ def known_kind_names(self) -> set[str]: def parse_kind(rel_path: str, content: str) -> KindDefinition | None: doc = parse_page(content) fm = doc.frontmatter or {} - name = fm.get("name") or Path(rel_path).stem + name = str(fm.get("name") or Path(rel_path).stem) if not name: return None + required = fm.get("required_sections") + optional = fm.get("optional_sections") + parent = fm.get("parent_kind") return KindDefinition( - name=str(name), + name=name, display_name=str(fm.get("display_name", name)), - dir_name=str(fm.get("dir_name", name + "s")), - required_sections=[str(s) for s in fm.get("required_sections", []) or []], - optional_sections=[str(s) for s in fm.get("optional_sections", []) or []], - parent_kind=fm.get("parent_kind") or None, + dir_name=str(fm.get("dir_name", f"{name}s")), + required_sections=( + [str(s) for s in required] if isinstance(required, list) else [] + ), + optional_sections=( + [str(s) for s in optional] if isinstance(optional, list) else [] + ), + parent_kind=str(parent) if parent else None, autofill_prompt=str(fm.get("autofill_prompt", "")), ) diff --git a/mcp_server/core/wiki_sync.py b/mcp_server/core/wiki_sync.py index 643b6bcf..7b6b3c06 100644 --- a/mcp_server/core/wiki_sync.py +++ b/mcp_server/core/wiki_sync.py @@ -143,10 +143,11 @@ def _render_with_frontmatter( """ # Use the existing note builder for the body shape, then replace its # frontmatter with the 4-tuple-aware version. + raw_tags = frontmatter.get("tags") note_md = build_note( title=title, body=body, - tags=list(frontmatter.get("tags") or []), + tags=[str(t) for t in raw_tags] if isinstance(raw_tags, list) else [], updated=str(frontmatter.get("updated", "")), ) body_only = _strip_frontmatter(note_md) diff --git a/mcp_server/core/write_gate.py b/mcp_server/core/write_gate.py index 84dea556..fe21436e 100644 --- a/mcp_server/core/write_gate.py +++ b/mcp_server/core/write_gate.py @@ -356,13 +356,10 @@ def read_active_goal(store: Any) -> Any: the store is None, lacks the reader, has no active triggers, or the read fails. Per the source-discipline rule we never fabricate a goal signal. """ - if store is None: - return goal_maintenance.EMPTY_GOAL - reader = getattr(store, "get_active_prospective_memories", None) - if not callable(reader): + if store is None or not hasattr(store, "get_active_prospective_memories"): return goal_maintenance.EMPTY_GOAL try: - triggers = reader() + triggers = store.get_active_prospective_memories() except Exception as exc: # noqa: BLE001 — mechanism boundary — failure is observable via silent_failure ("write_gate.active_goal_read") silent_failure.note("write_gate.active_goal_read", exc) return goal_maintenance.EMPTY_GOAL @@ -460,9 +457,8 @@ def apply_habituation( signature = habituation.stimulus_signature(content) repeat_count, hours_since_last = 0, None salience, hours_since_salient = 0.0, None - reader = getattr(store, "signature_repeat_stats", None) - if callable(reader): - repeat_count, hours_since_last = reader(signature) + if hasattr(store, "signature_repeat_stats"): + repeat_count, hours_since_last = store.signature_repeat_stats(signature) # The current write's own importance is the salience source: a salient # write dishabituates itself and briefly sensitizes related inputs # (Rankin criteria 8/9). hours_since_salient=0.0 = the event is now. diff --git a/mcp_server/core/write_post_store.py b/mcp_server/core/write_post_store.py index 4d3c5b20..95e30323 100644 --- a/mcp_server/core/write_post_store.py +++ b/mcp_server/core/write_post_store.py @@ -273,9 +273,10 @@ def _get_slot_cache(store: Any, num_slots: int) -> list[dict]: store.init_engram_slots(num_slots) _slots_initialised = True - _slot_cache = store.get_all_engram_slots() + slots: list[dict] = store.get_all_engram_slots() + _slot_cache = slots _slot_cache_store_id = store_id - return _slot_cache + return slots def _update_slot_cache( diff --git a/mcp_server/handlers/codebase_analyze.py b/mcp_server/handlers/codebase_analyze.py index 56106424..25f35b14 100644 --- a/mcp_server/handlers/codebase_analyze.py +++ b/mcp_server/handlers/codebase_analyze.py @@ -405,7 +405,7 @@ def _run_graph_analysis( file_contents: dict[str, str], store: MemoryStore, domain: str, -) -> dict[str, int]: +) -> dict[str, int | list[str]]: """Run cross-file resolution, type references, and communities.""" import_edges = resolve_all_imports(analyses) diff --git a/mcp_server/handlers/consolidation/compression.py b/mcp_server/handlers/consolidation/compression.py index 48b0c49a..7cc3c9bd 100644 --- a/mcp_server/handlers/consolidation/compression.py +++ b/mcp_server/handlers/consolidation/compression.py @@ -107,7 +107,7 @@ def _compress_full_to_gist( embeddings: EmbeddingEngine, mem: dict, stats: dict, -) -> tuple[str, list[float]]: +) -> tuple[str, bytes | None]: """Compress from full text (level 0) to gist (level 1). Returns the freshly computed ``(gist, gist_embedding)`` so a caller @@ -144,7 +144,7 @@ def _compress_to_tag_from_gist( stats: dict, *, gist: str | None = None, - gist_emb: list[float] | None = None, + gist_emb: bytes | None = None, ) -> None: """Continue compression from a freshly created gist to tag (level 2). diff --git a/mcp_server/handlers/consolidation/embedding_upgrade.py b/mcp_server/handlers/consolidation/embedding_upgrade.py index 1b9cc5fd..24af1793 100644 --- a/mcp_server/handlers/consolidation/embedding_upgrade.py +++ b/mcp_server/handlers/consolidation/embedding_upgrade.py @@ -16,7 +16,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Protocol, runtime_checkable from mcp_server.infrastructure.embedding_engine import EmbeddingEngine from mcp_server.infrastructure.memory_store import MemoryStore @@ -29,6 +29,22 @@ _MAX_UPGRADE_PER_CYCLE = 100 +@runtime_checkable +class _FallbackWorklistStore(Protocol): + """Capability contract for the fallback re-embed worklist. + + The SQLite store implements it; PgMemoryStore intentionally does not + (PG installs always have the neural encoder), so absence is the + designed degraded mode — named, not silent. runtime_checkable + isinstance resolves members statically (3.12+ getattr_static), which + real stores and test fakes with real methods both satisfy. + """ + + def select_fallback_embeddings(self, limit: int = ...) -> list[dict]: ... + + def reembed_memory(self, memory_id: int, embedding: bytes | None) -> None: ... + + def run_embedding_upgrade_cycle( store: MemoryStore, embeddings: EmbeddingEngine ) -> dict[str, Any]: @@ -39,9 +55,7 @@ def run_embedding_upgrade_cycle( upgraded memory is re-embedded and restamped 'neural' via ``store.reembed_memory``. Non-fatal: failures report zero upgrades. """ - if not hasattr(store, "select_fallback_embeddings") or not hasattr( - store, "reembed_memory" - ): + if not isinstance(store, _FallbackWorklistStore): return {"upgraded": 0, "reason": "store has no fallback worklist"} if getattr(embeddings, "mode", "neural") != "neural": return {"upgraded": 0, "reason": "no neural model available yet"} diff --git a/mcp_server/handlers/consolidation/forgetting.py b/mcp_server/handlers/consolidation/forgetting.py index d3452ff7..dfbb71c4 100644 --- a/mcp_server/handlers/consolidation/forgetting.py +++ b/mcp_server/handlers/consolidation/forgetting.py @@ -132,8 +132,15 @@ def _evaluate_memory( return "retain" memory_id = int(mem["id"]) - neighbors = store.search_newer_neighbors( - embedding, mem.get("created_at"), memory_id, top_k=NEIGHBOR_K + created_at = mem.get("created_at") + # A row without created_at cannot have "newer" neighbors — the SQL + # comparison against NULL matched no rows; make that explicit. + neighbors = ( + store.search_newer_neighbors( + embedding, str(created_at), memory_id, top_k=NEIGHBOR_K + ) + if created_at is not None + else [] ) chronic = chronic_interference(sim for sim, _ in neighbors) acute_overlap, acute_age_hours = neighbors[0] if neighbors else (0.0, float("inf")) diff --git a/mcp_server/handlers/consolidation/memify.py b/mcp_server/handlers/consolidation/memify.py index 1d9b3b65..8b90a10a 100644 --- a/mcp_server/handlers/consolidation/memify.py +++ b/mcp_server/handlers/consolidation/memify.py @@ -63,7 +63,7 @@ def run_memify_cycle( pruned, strengthened, flags, scanned = _stream_prune_strengthen(store, memories) reweighted = _reweight_relationships(store) - stats = { + stats: dict[str, int | str] = { "pruned": pruned, "strengthened": strengthened, "reweighted": reweighted, diff --git a/mcp_server/handlers/get_causal_chain.py b/mcp_server/handlers/get_causal_chain.py index 3f954f90..64889678 100644 --- a/mcp_server/handlers/get_causal_chain.py +++ b/mcp_server/handlers/get_causal_chain.py @@ -273,7 +273,7 @@ async def _handler_impl(args: dict[str, Any] | None = None) -> dict[str, Any]: store = _get_store() start_entity, error = _resolve_start_entity(args, store) if not start_entity: - return _build_empty_result(error) + return _build_empty_result(error or "start entity not found") edges = _bfs_entity_graph( start_entity_id=start_entity["id"], diff --git a/mcp_server/handlers/ingest_helpers.py b/mcp_server/handlers/ingest_helpers.py index d59c185d..99540940 100644 --- a/mcp_server/handlers/ingest_helpers.py +++ b/mcp_server/handlers/ingest_helpers.py @@ -14,6 +14,8 @@ from __future__ import annotations +from datetime import date, datetime + import hashlib import json import os @@ -91,8 +93,9 @@ def _memo_recency_key(mem: dict) -> str: val = mem.get(field) if val is None or val == "": continue - iso = getattr(val, "isoformat", None) - return iso() if callable(iso) else str(val) + if isinstance(val, (datetime, date)): + return val.isoformat() + return str(val) return "" diff --git a/mcp_server/handlers/query_methodology.py b/mcp_server/handlers/query_methodology.py index 957aeb84..ce2c44b3 100644 --- a/mcp_server/handlers/query_methodology.py +++ b/mcp_server/handlers/query_methodology.py @@ -334,7 +334,7 @@ async def handler(args: dict | None = None) -> dict: return _bounded( _build_profile_response( - domain_id, + str(domain_id), profile, detection, context, diff --git a/mcp_server/handlers/recall_hierarchical.py b/mcp_server/handlers/recall_hierarchical.py index bab068f3..55148dbd 100644 --- a/mcp_server/handlers/recall_hierarchical.py +++ b/mcp_server/handlers/recall_hierarchical.py @@ -200,7 +200,7 @@ def _enrich_results( def _score_memories_against_hierarchy( memories: list[dict], - query_embedding: list[float], + query_embedding: bytes, query: str, embeddings: EmbeddingEngine, cluster_threshold: float, diff --git a/mcp_server/handlers/recall_skills.py b/mcp_server/handlers/recall_skills.py index 428339e2..b1997d11 100644 --- a/mcp_server/handlers/recall_skills.py +++ b/mcp_server/handlers/recall_skills.py @@ -11,6 +11,8 @@ from __future__ import annotations +from typing import Protocol, runtime_checkable + import logging from mcp_server.core.procedural_memory import ( @@ -94,6 +96,20 @@ } +@runtime_checkable +class _ProceduralSkillsStore(Protocol): + """Capability contract for the procedural-skills subsystem (PG-only). + + The SQLite backend has no procedural_skills table; the empty result is + the named degraded mode, decided by a static member check rather than + an AttributeError swallowed by the broad except below. + """ + + def get_procedural_skills( + self, min_proficiency: float = ..., limit: int = ... + ) -> list[dict]: ... + + def _row_to_skill(row: dict) -> ProceduralSkill: """Rebuild a ProceduralSkill from a stored procedural_skills row. @@ -131,6 +147,9 @@ async def handler(args: dict) -> dict: min_proficiency = float(args.get("min_proficiency", 0.5)) store = get_shared_store() + if not isinstance(store, _ProceduralSkillsStore): + logger.info("recall_skills: backend has no procedural-skills store") + return {"skills": [], "count": 0} try: rows = store.get_procedural_skills(min_proficiency=min_proficiency) except Exception as exc: # noqa: BLE001 — store not migrated / unavailable diff --git a/mcp_server/handlers/remember_helpers.py b/mcp_server/handlers/remember_helpers.py index 9ffbc0a8..412cd17c 100644 --- a/mcp_server/handlers/remember_helpers.py +++ b/mcp_server/handlers/remember_helpers.py @@ -427,7 +427,7 @@ def try_block_replica_upsert( if not rows: return False, None - existing_id = rows[0]["id"] if isinstance(rows[0], dict) else rows[0][0] + existing_id = rows[0]["id"] # Refresh content, embedding, tags, source; preserve heat and is_protected. diff --git a/mcp_server/handlers/seed_project_stages.py b/mcp_server/handlers/seed_project_stages.py index b6ad1306..7186add2 100644 --- a/mcp_server/handlers/seed_project_stages.py +++ b/mcp_server/handlers/seed_project_stages.py @@ -148,7 +148,7 @@ def _harvest_doc_dirs(root: Path, max_bytes: int, seen: set[Path]) -> list[dict] def stage_docs(root: Path, max_bytes: int) -> list[dict]: """Harvest documentation files.""" root_docs = _harvest_root_docs(root, max_bytes) - seen = {d.get("_path") for d in root_docs if "_path" in d} + seen = {p for d in root_docs if (p := d.get("_path")) is not None} for d in root_docs: d.pop("_path", None) dir_docs = _harvest_doc_dirs(root, max_bytes, seen) diff --git a/mcp_server/handlers/wiki_migrate.py b/mcp_server/handlers/wiki_migrate.py index 98ebdba6..bc9fe39b 100644 --- a/mcp_server/handlers/wiki_migrate.py +++ b/mcp_server/handlers/wiki_migrate.py @@ -193,16 +193,20 @@ def page_row_from_md( has_domain_component = len(parts) >= _DOMAIN_PATH_PARTS domain = fm.get("domain") or (parts[1] if has_domain_component else "_general") - tags = fm.get("tags", []) - if isinstance(tags, str): - tags = [tags] + raw_tags = fm.get("tags", []) + if isinstance(raw_tags, str): + tags = [raw_tags] + elif isinstance(raw_tags, list): + tags = [str(t) for t in raw_tags] + else: + tags = [] # ADR-0051 STEP 2: extract every legacy documented-file form # (documents:/source_file_path:/file:/file: tag) into the # canonical list. documents_primary is the 1:1 fast-path scalar. documents = extract_document_paths(fm, tags) - raw_status = fm.get("status") or fm.get("maturity") or "seedling" + raw_status = str(fm.get("status") or fm.get("maturity") or "seedling") status, status_warning = _normalize_status(raw_status, rel_path) return { diff --git a/mcp_server/handlers/wiki_resolve.py b/mcp_server/handlers/wiki_resolve.py index 01a1267d..12a211b8 100644 --- a/mcp_server/handlers/wiki_resolve.py +++ b/mcp_server/handlers/wiki_resolve.py @@ -171,7 +171,9 @@ async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]: # Compute the planned entity sets so we can pull priors for THOSE entities candidate_entities: set[int] = set() for c in claims: - candidate_entities.update(entities_by_memory.get(c.get("memory_id"), [])) + claim_memory_id = c.get("memory_id") + if claim_memory_id is not None: + candidate_entities.update(entities_by_memory.get(claim_memory_id, [])) excl_ids = [c["id"] for c in claims] prior_claims_by_entity = get_claims_by_entity( conn, list(candidate_entities), exclude_claim_ids=excl_ids diff --git a/mcp_server/handlers/wiki_view.py b/mcp_server/handlers/wiki_view.py index 896b08b2..bd9f0f70 100644 --- a/mcp_server/handlers/wiki_view.py +++ b/mcp_server/handlers/wiki_view.py @@ -17,7 +17,12 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, TypeGuard, cast + +if TYPE_CHECKING: + from typing_extensions import LiteralString + + from mcp_server.infrastructure.pg_store import PgMemoryStore # psycopg.rows is imported lazily inside _execute_view() — only reached on # the PG path. Under the SQLite fallback psycopg may not be installed, so an @@ -27,7 +32,7 @@ # // which also defers `import psycopg` to _try_pg_verbose). from mcp_server.infrastructure.wiki_schema_reader import load_registry -from mcp_server.core.wiki_view_executor import compile_view +from mcp_server.core.wiki_view_executor import CompiledView, compile_view from mcp_server.infrastructure.config import WIKI_ROOT from mcp_server.infrastructure.memory_config import get_memory_settings from mcp_server.infrastructure.memory_store import MemoryStore, get_shared_store @@ -106,7 +111,7 @@ def _get_store() -> MemoryStore: return get_shared_store(settings.DB_PATH, settings.EMBEDDING_DIM) -def _is_pg(store: object) -> bool: +def _is_pg(store: object) -> TypeGuard[PgMemoryStore]: """Return True iff the store is the PostgreSQL backend. precondition: store is a MemoryStore produced by get_shared_store() @@ -120,7 +125,9 @@ def _is_pg(store: object) -> bool: return type(store).__name__ == "PgMemoryStore" -def _execute_view(store: object, compiled: object, view_meta: dict) -> dict: +def _execute_view( + store: PgMemoryStore, compiled: CompiledView, view_meta: dict +) -> dict: """Run a compiled view against the PG store and return the result dict. precondition: _is_pg(store) is True; compiled.ok is True @@ -132,7 +139,10 @@ def _execute_view(store: object, compiled: object, view_meta: dict) -> dict: with store._conn.cursor(row_factory=dict_row) as cur: try: - cur.execute(compiled.sql, compiled.params) + # compiled.sql comes from the safe view compiler: table/column + # whitelists, values as bound params (wiki_view_executor) — the + # LiteralString discipline is re-asserted at this one boundary. + cur.execute(cast("LiteralString", compiled.sql), compiled.params) rows = list(cur.fetchall()) except Exception as e: # noqa: BLE001 — view execution boundary — any SQL failure is returned as the view's error report return { diff --git a/mcp_server/hooks/agent_briefing.py b/mcp_server/hooks/agent_briefing.py index 9fa73b1f..0237d0d9 100644 --- a/mcp_server/hooks/agent_briefing.py +++ b/mcp_server/hooks/agent_briefing.py @@ -255,11 +255,13 @@ def _connect(): """Open the briefing's PG connection; None when PG is unreachable.""" try: import psycopg # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode - from psycopg.rows import dict_row # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode + from psycopg.rows import DictRow, dict_row # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode except ImportError: return None try: - return psycopg.connect(_DATABASE_URL, row_factory=dict_row, autocommit=True) + return psycopg.Connection[DictRow].connect( + _DATABASE_URL, row_factory=dict_row, autocommit=True + ) except psycopg.Error: return None diff --git a/mcp_server/hooks/auto_recall.py b/mcp_server/hooks/auto_recall.py index 3519b648..57e593da 100644 --- a/mcp_server/hooks/auto_recall.py +++ b/mcp_server/hooks/auto_recall.py @@ -135,11 +135,13 @@ def _connect(): """Open the hook's PG connection; None when PG is unreachable.""" try: import psycopg # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode - from psycopg.rows import dict_row # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode + from psycopg.rows import DictRow, dict_row # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode except ImportError: return None try: - return psycopg.connect(_DATABASE_URL, row_factory=dict_row, autocommit=True) + return psycopg.Connection[DictRow].connect( + _DATABASE_URL, row_factory=dict_row, autocommit=True + ) except psycopg.Error: return None diff --git a/mcp_server/hooks/compaction_checkpoint.py b/mcp_server/hooks/compaction_checkpoint.py index 57905094..8c66c4ac 100644 --- a/mcp_server/hooks/compaction_checkpoint.py +++ b/mcp_server/hooks/compaction_checkpoint.py @@ -55,11 +55,11 @@ def process_event(event: dict[str, Any] | None) -> None: session_id_from_transcript, ) from mcp_server.infrastructure.memory_config import get_memory_settings # noqa: PLC0415 — hook latency boundary: the per-event hook process defers the handler/store stack (hook boot ~0.05 s vs ~0.6 s registry import, measured 2026-07-28) - from mcp_server.infrastructure.memory_store import MemoryStore # noqa: PLC0415 — hook latency boundary: the per-event hook process defers the handler/store stack (hook boot ~0.05 s vs ~0.6 s registry import, measured 2026-07-28) + from mcp_server.infrastructure.memory_store import get_shared_store # noqa: PLC0415 — hook latency boundary: the per-event hook process defers the handler/store stack (hook boot ~0.05 s vs ~0.6 s registry import, measured 2026-07-28) # Increment epoch BEFORE saving checkpoint so the new epoch is recorded settings = get_memory_settings() - store = MemoryStore(settings.DB_PATH, settings.EMBEDDING_DIM) + store = get_shared_store(settings.DB_PATH, settings.EMBEDDING_DIM) new_epoch = store.increment_epoch() _log(f"Epoch incremented to {new_epoch}") diff --git a/mcp_server/hooks/pipeline_impact_bump.py b/mcp_server/hooks/pipeline_impact_bump.py index 81ba40ef..2933424c 100644 --- a/mcp_server/hooks/pipeline_impact_bump.py +++ b/mcp_server/hooks/pipeline_impact_bump.py @@ -33,13 +33,18 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing_extensions import LiteralString + import asyncio import json import os import sys import time from pathlib import Path -from typing import Any +from typing import Any, cast _LOG_PREFIX = "[pipeline-impact-bump]" _COOLDOWN_SECONDS = 30 @@ -103,12 +108,12 @@ async def _pipeline_detect_changes(project_root: str, file_path: str) -> list[st find_cached_graph, normalise_mcp_payload, ) - from mcp_server.infrastructure.memory_store import MemoryStore # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode + from mcp_server.infrastructure.memory_store import get_shared_store # noqa: PLC0415 — optional-feature probe: ImportError here is a handled degraded mode except ImportError: return [] try: - store = MemoryStore() + store = get_shared_store() graph_path = find_cached_graph(store, project_root) except Exception as exc: # noqa: BLE001 — hook boundary; failure is logged to the hook log, the banner degrades _log(f"cached-graph lookup failed: {exc}") @@ -182,7 +187,7 @@ def _bump_heat_for_symbols(symbol_names: list[str]) -> int: " AND heat_base < 1.0 " " AND (" + like_clauses + ")" ) - result = conn.execute(sql, [_IMPACT_BOOST, *params]) + result = conn.execute(cast("LiteralString", sql), [_IMPACT_BOOST, *params]) count = result.rowcount if result else 0 except Exception as exc: # noqa: BLE001 — hook boundary — failure is logged to the hook log; the hook stays non-fatal _log(f"heat bump failed: {exc}") diff --git a/mcp_server/hooks/post_tool_capture.py b/mcp_server/hooks/post_tool_capture.py index ddd7e457..545e6556 100644 --- a/mcp_server/hooks/post_tool_capture.py +++ b/mcp_server/hooks/post_tool_capture.py @@ -351,9 +351,9 @@ def _maybe_run_cascade() -> None: from mcp_server.handlers.consolidation.cascade import ( # noqa: PLC0415 — hook latency boundary: the per-event hook process defers the handler/store stack (hook boot ~0.05 s vs ~0.6 s registry import, measured 2026-07-28) run_cascade_advancement, ) - from mcp_server.infrastructure.memory_store import MemoryStore # noqa: PLC0415 — hook latency boundary: the per-event hook process defers the handler/store stack (hook boot ~0.05 s vs ~0.6 s registry import, measured 2026-07-28) + from mcp_server.infrastructure.memory_store import get_shared_store # noqa: PLC0415 — hook latency boundary: the per-event hook process defers the handler/store stack (hook boot ~0.05 s vs ~0.6 s registry import, measured 2026-07-28) - store = MemoryStore() + store = get_shared_store() result = run_cascade_advancement(store) advanced = result.get("advanced", 0) if advanced > 0: diff --git a/mcp_server/hooks/session_start.py b/mcp_server/hooks/session_start.py index 224b443c..f445918c 100644 --- a/mcp_server/hooks/session_start.py +++ b/mcp_server/hooks/session_start.py @@ -113,9 +113,11 @@ def _connect_pg(): """Try to connect to PostgreSQL. Returns connection or None.""" try: import psycopg # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working - from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working + from psycopg.rows import DictRow, dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working - conn = psycopg.connect(_DATABASE_URL, row_factory=dict_row, autocommit=True) + conn = psycopg.Connection[DictRow].connect( + _DATABASE_URL, row_factory=dict_row, autocommit=True + ) return conn except Exception as exc: # noqa: BLE001 — hook boundary — failure is logged to the hook log; the hook stays non-fatal _log(f"PostgreSQL connect failed: {exc}") diff --git a/mcp_server/infrastructure/pg_store_wiki_sources.py b/mcp_server/infrastructure/pg_store_wiki_sources.py index fa288112..e9539aec 100644 --- a/mcp_server/infrastructure/pg_store_wiki_sources.py +++ b/mcp_server/infrastructure/pg_store_wiki_sources.py @@ -11,6 +11,8 @@ from __future__ import annotations +from collections.abc import Sequence + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -83,7 +85,7 @@ def _entry_row( def upsert_page_sources( conn: StoreConnection, page_id: int, - documents: list[SourceEntry], + documents: Sequence[SourceEntry], *, link_kind: str = "documents", source: str = "frontmatter", diff --git a/mcp_server/infrastructure/sqlite_store.py b/mcp_server/infrastructure/sqlite_store.py index b661dded..bb954125 100644 --- a/mcp_server/infrastructure/sqlite_store.py +++ b/mcp_server/infrastructure/sqlite_store.py @@ -16,6 +16,8 @@ import json import logging import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -701,6 +703,104 @@ def update_memory_extinction( # documented no-op per the docstring above. pass + # ── Connection acquisition (PgMemoryStore parity) ───────────────── + # + # PgMemoryStore splits connections across an interactive pool and a batch + # pool so long-running jobs cannot starve the hot path. SQLite has no + # such split to make: the store owns exactly one WAL-mode connection, and + # a second competing connection is what produces `database is locked` / + # stale-read failures under WAL. Both accessors therefore yield the same + # persistent connection — the identical shape PgMemoryStore itself yields + # when POOL_DISABLED is set (pg_store.py acquire_* kill-switch path). + # + # These exist so handlers stay backend-agnostic: anchor.py, get_rules.py, + # the codebase_analyze/backfill/consolidation writers all call + # `store.acquire_*()` unconditionally. Without them a SQLite-backed + # install raised AttributeError — an LSP break, not a missing feature, + # since the handler cannot know which store it was handed (issue #220). + + @contextmanager + def acquire_interactive(self) -> Iterator[PsycopgCompatConnection]: + """Yield the store's connection for a short-lived hot-path operation.""" + yield self._conn + + @contextmanager + def acquire_batch(self) -> Iterator[PsycopgCompatConnection]: + """Yield the store's connection for long-running batch work.""" + yield self._conn + + def _execute(self, query: str, params: Any = None) -> Any: + """PgMemoryStore-parity passthrough to the translating connection. + + PG-flavored SQL a caller sends here (``%s`` placeholders, ``NOW()``, + ``= ANY(...)``) is translated by ``PsycopgCompatConnection``; a + construct SQLite cannot express (e.g. ``@>`` jsonb containment) + raises ``sqlite3.OperationalError``, which the callers' documented + mechanism boundaries treat as the degraded mode — previously the + same sites raised ``AttributeError`` before the query even ran + (issue #220). + """ + return self._conn.execute(query, params) + + def search_newer_neighbors( + self, + query_embedding: bytes, + after: str, + exclude_id: int, + top_k: int = 10, + ) -> list[tuple[float, float]]: + """Vector neighbors created strictly after ``after``, nearest first. + + PgMemoryStore parity (see ``pg_store.py::search_newer_neighbors``): + returns ``(similarity, age_hours)`` per newer neighbor. SQLite has no + ``<=>`` operator, so the candidate rows are scored with the same + numpy cosine the fallback vector search uses. + """ + query_vec = self._bytes_to_vector(query_embedding) + if query_vec is None: + return [] + q_norm = float(np.linalg.norm(query_vec)) + if q_norm == 0.0: + return [] + rows = self._conn.execute( + "SELECT id, embedding, created_at FROM memories " + "WHERE created_at > ? AND id != ? AND is_stale = 0 " + "AND embedding IS NOT NULL", + (after, exclude_id), + ).fetchall() + now = datetime.now(timezone.utc) + scored: list[tuple[float, float]] = [] + for r in rows: + vec = self._bytes_to_vector(r["embedding"]) + if vec is None or vec.shape != query_vec.shape: + continue + denom = q_norm * float(np.linalg.norm(vec)) + if denom == 0.0: + continue + similarity = float(np.dot(query_vec, vec)) / denom + try: + created = datetime.fromisoformat(str(r["created_at"])) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + age_hours = (now - created).total_seconds() / 3600.0 + except ValueError: + age_hours = float("inf") + scored.append((similarity, age_hours)) + scored.sort(key=lambda pair: pair[0], reverse=True) + return scored[:top_k] + + def update_forgetting_pressure_accum(self, memory_id: int, accum: float) -> None: + """Persist the permanent-circuit leaky-integrator state for one memory. + + PgMemoryStore parity — see ``pg_store.py`` for the rationale + (source: mcp_server/core/active_forgetting.py, update_pressure_accum). + """ + self._conn.execute( + "UPDATE memories SET forgetting_pressure_accum = ? WHERE id = ?", + (accum, memory_id), + ) + self._conn.commit() + def _stamp_embedding_model(self, memory_id: int, model: str | None) -> None: """Record which vector space a memory's embedding lives in (issue #169). diff --git a/mcp_server/infrastructure/sqlite_store_queries.py b/mcp_server/infrastructure/sqlite_store_queries.py index 500edb7f..99037149 100644 --- a/mcp_server/infrastructure/sqlite_store_queries.py +++ b/mcp_server/infrastructure/sqlite_store_queries.py @@ -4,7 +4,7 @@ import json import sqlite3 -from typing import Any +from typing import Any, Iterator from mcp_server.infrastructure.sqlite_compat import PsycopgCompatConnection from mcp_server.observability import silent_failure @@ -136,6 +136,64 @@ def get_all_memories_for_decay(self) -> list[dict[str, Any]]: ).fetchall() return [self._normalize_memory_row(r) for r in rows] + def get_memories_by_tag(self, tag: str, limit: int = 20) -> list[dict[str, Any]]: + """Most-recent-first memories carrying ``tag``. + + PgMemoryStore parity (``pg_store_queries.py::get_memories_by_tag``). + SQLite lacks the ``@>`` jsonb containment operator; ``json_each`` + (JSON1 — already a baseline: the compat translator expands + ``= ANY(...)`` through it) gives the same containment predicate + without a full-table Python filter. + """ + rows = self._conn.execute( + "SELECT m.* FROM memories m " + "WHERE m.is_stale = 0 AND EXISTS (" + " SELECT 1 FROM json_each(m.tags) je WHERE je.value = ?" + ") ORDER BY m.created_at DESC LIMIT ?", + (tag, limit), + ).fetchall() + return [self._normalize_memory_row(r) for r in rows] + + def iter_memories_for_decay( + self, + chunk_size: int = 1000, + ) -> Iterator[list[dict[str, Any]]]: + """Stream active memories for decay — PgMemoryStore parity. + + PG streams chunks through a server-side cursor because its corpora + reach 500k+ rows; SQLite serves the local plugin install, where the + corpus fits in memory and the store already materializes it for + every other decay path. One yielded chunk therefore matches + PgMemoryStore's own ``POOL_DISABLED`` compatibility path exactly. + ``chunk_size`` is accepted for signature parity and unused. + """ + del chunk_size # signature parity with PgMemoryStore + yield self.get_all_memories_for_decay() + + def find_co_accessed_pairs(self, memory_ids: list[int]) -> list[tuple[int, int]]: + """Entity pairs co-occurring in any of the sampled memories. + + PgMemoryStore parity (``pg_store_queries.py::find_co_accessed_pairs``): + the same ``memory_entities`` self-join, with SQLite's two-argument + ``MIN``/``MAX`` scalars standing in for ``LEAST``/``GREATEST`` and an + expanded ``IN`` list standing in for ``= ANY``. + """ + if not memory_ids: + return [] + placeholders = ", ".join("?" for _ in memory_ids) + rows = self._conn.execute( + "SELECT DISTINCT " # noqa: S608 — placeholders are generated "?" markers; ids travel as bound parameters (docs/ASSURANCE-CASE.md §5) + " MIN(me1.entity_id, me2.entity_id) AS a, " + " MAX(me1.entity_id, me2.entity_id) AS b " + "FROM memory_entities me1 " + "JOIN memory_entities me2 " + " ON me1.memory_id = me2.memory_id " + " AND me1.entity_id < me2.entity_id " + f"WHERE me1.memory_id IN ({placeholders})", + [int(m) for m in memory_ids], + ).fetchall() + return [(int(r["a"]), int(r["b"])) for r in rows] + def delete_memories_by_tag(self, tag: str, domain: str | None = None) -> int: """Delete memories with the given tag, optionally scoped to a domain. diff --git a/mcp_server/migrate.py b/mcp_server/migrate.py index 123638c0..37311174 100644 --- a/mcp_server/migrate.py +++ b/mcp_server/migrate.py @@ -80,11 +80,11 @@ def _probe_was_current(url: str, target_hash: str) -> bool: distinct, separately-tested error paths (see module docstring). """ import psycopg # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working - from psycopg.rows import dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working + from psycopg.rows import DictRow, dict_row # noqa: PLC0415 — optional dependency ([postgresql] extra); imported where used so environments without it keep working from mcp_server.infrastructure.pg_store import read_schema_hash # noqa: PLC0415 — deferred: module hard-imports pgvector/psycopg/psycopg_pool at top level; hoisting would break installs without it - with psycopg.connect( + with psycopg.Connection[DictRow].connect( url, # source: doctor.py::_pg_connection / doctor_mcp.py:458 use # connect_timeout=5 for a live health-check probe; this probe is diff --git a/mcp_server/shared/json_native.py b/mcp_server/shared/json_native.py index c3c51636..cd64821b 100644 --- a/mcp_server/shared/json_native.py +++ b/mcp_server/shared/json_native.py @@ -30,7 +30,7 @@ import datetime as _dt import logging import numbers -from typing import Any +from typing import Any, SupportsFloat, cast logger = logging.getLogger(__name__) @@ -64,7 +64,9 @@ def to_json_native(obj: Any) -> Any: # to Decimal via psycopg). complex has no float() → falls through to # the str fallback below, which stays JSON-safe. try: - return float(obj) + # Decimal implements __float__; complex does not and lands in + # the except arm — exactly the documented fallback contract. + return float(cast("SupportsFloat", obj)) except (TypeError, ValueError): pass # numpy arrays AND 0-d scalars (incl. numpy.bool_) expose tolist(), diff --git a/mcp_server/shared/wiki_source_paths.py b/mcp_server/shared/wiki_source_paths.py index b2ee8929..c8b25586 100644 --- a/mcp_server/shared/wiki_source_paths.py +++ b/mcp_server/shared/wiki_source_paths.py @@ -26,6 +26,8 @@ from __future__ import annotations +from collections.abc import Mapping + _FILE_TAG_PREFIX = "file:" @@ -48,7 +50,7 @@ def normalize_source_path(raw: str) -> str | None: def extract_document_paths( - frontmatter: dict[str, object], tags: list[str] | None = None + frontmatter: Mapping[str, object], tags: list[str] | None = None ) -> list[str]: """Pull every documented-file path out of a page's frontmatter + tags. diff --git a/mcp_server/tool_error_handler.py b/mcp_server/tool_error_handler.py index 28ca73b4..b7ce6882 100644 --- a/mcp_server/tool_error_handler.py +++ b/mcp_server/tool_error_handler.py @@ -46,7 +46,7 @@ async def tool_remember(...) -> dict: import asyncio import logging -from typing import Any, Callable, Coroutine +from typing import Any, Awaitable, Callable from fastmcp.exceptions import ToolError @@ -124,7 +124,7 @@ def _classify_error(exc: Exception) -> tuple[str, str]: def _run_coroutine_on_thread( - handler_fn: Callable[..., Coroutine[Any, Any, dict]], + handler_fn: Callable[..., Awaitable[dict]], args: dict[str, Any], ) -> dict: """Run an async handler's coroutine on a fresh event loop in a worker thread. @@ -150,7 +150,7 @@ def _run_coroutine_on_thread( async def safe_handler( - handler_fn: Callable[..., Coroutine[Any, Any, dict]], + handler_fn: Callable[..., Awaitable[dict]], args: dict[str, Any], tool_name: str | None = None, ) -> dict[str, Any]: diff --git a/tests_py/hooks/test_agent_briefing.py b/tests_py/hooks/test_agent_briefing.py index cd087bd7..5e753980 100644 --- a/tests_py/hooks/test_agent_briefing.py +++ b/tests_py/hooks/test_agent_briefing.py @@ -131,7 +131,11 @@ class _FakePgError(Exception): def test_connect_with_unreachable_database_returns_none(monkeypatch): module = MagicMock() module.Error = _FakePgError - module.connect.side_effect = _FakePgError("connection refused") + # The hook connects via psycopg.Connection[DictRow].connect(...) so the + # returned connection is typed with dict rows; mirror that call path. + module.Connection.__getitem__.return_value.connect.side_effect = _FakePgError( + "connection refused" + ) monkeypatch.setitem(sys.modules, "psycopg", module) assert hook._connect() is None diff --git a/tests_py/hooks/test_ble001_sweep_hooks.py b/tests_py/hooks/test_ble001_sweep_hooks.py index 765c7fca..9f08f826 100644 --- a/tests_py/hooks/test_ble001_sweep_hooks.py +++ b/tests_py/hooks/test_ble001_sweep_hooks.py @@ -86,7 +86,7 @@ def test_store_failure_degrades_to_no_symbols_and_logs(self, capsys, monkeypatch def broken(*args, **kwargs): raise RuntimeError("store init broke") - monkeypatch.setattr(memory_store, "MemoryStore", broken) + monkeypatch.setattr(memory_store, "get_shared_store", broken) out = asyncio.run( pipeline_impact_bump._pipeline_detect_changes("/proj", "/proj/a.py") ) From dfd656d4157673425da3c582968a3b84057c3b41 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 15:15:55 +0200 Subject: [PATCH 4/8] test(invariants): re-pin I2 canonical heat-writer lines after type-fix shifts Same writers, no new ones: the pg_store MaterializedCursor move and the sqlite_store parity-method insertions shifted seven pinned line numbers (pg_store 720->694/762->736/860->834, sqlite_store 537->543/567->573/ 631->637, pipeline_impact_bump 177->182). Verified each target is the identical pre-existing writer. Refs #197 Co-Authored-By: Claude Fable 5 --- tests_py/invariants/test_I2_canonical_writer.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests_py/invariants/test_I2_canonical_writer.py b/tests_py/invariants/test_I2_canonical_writer.py index b0ed1055..4a7027e7 100644 --- a/tests_py/invariants/test_I2_canonical_writer.py +++ b/tests_py/invariants/test_I2_canonical_writer.py @@ -61,14 +61,14 @@ # family 4): function-level imports above these sites hoisted to the # module top; same writers, no new ones (the test was the oracle, # as on every prior re-pin). - ("infrastructure/pg_store.py", 720), + ("infrastructure/pg_store.py", 694), # Canonical single-row writer (all callers route through this). # bump_heat_raw — the one canonical single-row site (re-pinned after # rebasing the read-path PR onto blame-path injection-receipts). # Shifted 727->728 by the same import-line addition above. # Shifted 737->766 by the module-level hash helpers extraction (same # net +29 cause as the entry above). - ("infrastructure/pg_store.py", 762), + ("infrastructure/pg_store.py", 736), # A3 batched writer (homeostatic cohort branch + any other batch consumer). # update_memories_heat_batch. Shifted 787->825 when M-D3 (7.1) added # get_homeostatic_factor/set_homeostatic_factor's write_class parameter @@ -76,19 +76,19 @@ # silent-except-sweep import-line addition. # Shifted 835->864 by the module-level hash helpers extraction (same # net +29 cause as the two entries above). - ("infrastructure/pg_store.py", 860), + ("infrastructure/pg_store.py", 834), # SQLite parity of the anchor transfer (same transactional rationale). # Shifted 389->440->447->493->529 (M-D3, then #169 added _fts_augment / # _migrate_fts_code_tokenize / unconditional embedding_model stamp above it; # then #206 added _register_json_codec above the class, +36 lines). - ("infrastructure/sqlite_store.py", 537), + ("infrastructure/sqlite_store.py", 543), # SQLite parity: canonical bump_heat_raw / update_memories_heat_batch. # Shifted 419->470->477->523->559->562, 463->534->541->587->623->626 for # the same # reasons (#169's _stamp_embedding_model / select_fallback_embeddings / # reembed_memory, then #206's _register_json_codec). - ("infrastructure/sqlite_store.py", 567), - ("infrastructure/sqlite_store.py", 631), + ("infrastructure/sqlite_store.py", 573), + ("infrastructure/sqlite_store.py", 637), # Homeostatic fold (amortized ~once/month per (domain, write_class)). # M-D3 (7.1, 2026-07-10): split out of homeostatic.py into # homeostatic_apply.py (§4.1 500-line file cap — stratification by @@ -101,7 +101,7 @@ ("hooks/preemptive_context.py", 145), # Pipeline-impact boost: heat_base += 0.15 for symbols touched by an # edit, resolved via pipeline detect_changes (PostToolUse hook). - ("hooks/pipeline_impact_bump.py", 177), + ("hooks/pipeline_impact_bump.py", 182), # I6-D5 deliberate re-heat campaign (INC6.6): CAS-guarded single-row # writer. Cannot route through bump_heat_raw — that would (1) turn a # concurrent-write race into a silent overwrite instead of a detected From 8159f4b93279b4a9c7f2bffd9d86a103f2c93e27 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 15:24:04 +0200 Subject: [PATCH 5/8] test(regression): pin the contracts the #197 type burn-down fixed - test_sqlite_parity_197: the full SQLite parity surface (acquire_*, _execute translation + untranslatable-SQL degraded mode, get_memories_by_tag containment/recency/stale/substring, iter_memories_for_decay chunk parity, find_co_accessed_pairs ordering/dedup, search_newer_neighbors newer-only/self-exclusion/ similarity order, update_forgetting_pressure_accum persistence), compat executemany translation, the lastrowid-None ProgrammingError, and MaterializedCursor.one (row + guaranteed-row violation). - update_style_ema(None, None) returns {} (was None against a dict signature). - encode_session refuses a direction-less Feature loudly, naming it (was a bare TypeError inside omp). - pipeline installer: a rust_install result claiming success without a cargo path is refused as missing_toolchain (was None in the argv). - forgetting: a memory without created_at yields no newer neighbors and never queries the store with a None bound. Refs #197 Co-Authored-By: Claude Fable 5 --- tests_py/core/test_sparse_dictionary.py | 31 +++ tests_py/core/test_style_classifier.py | 5 + tests_py/handlers/test_forgetting_cycle.py | 19 ++ .../infrastructure/test_pipeline_discovery.py | 27 +++ .../infrastructure/test_sqlite_parity_197.py | 218 ++++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 tests_py/infrastructure/test_sqlite_parity_197.py diff --git a/tests_py/core/test_sparse_dictionary.py b/tests_py/core/test_sparse_dictionary.py index 049c446d..4e920fd0 100644 --- a/tests_py/core/test_sparse_dictionary.py +++ b/tests_py/core/test_sparse_dictionary.py @@ -208,3 +208,34 @@ def test_generates_meaningful_label(self): def test_handles_zero_direction(self): result = label_feature([0.0] * 27, 5) assert result.label == "feature-5" + + +class TestEncodeSessionDirectionContract: + def test_directionless_feature_raises_with_identity(self): + # Regression (#197 type burn-down): Feature.direction is Optional + # (extra="ignore" deserialization); encoding against a + # direction-less dictionary used to die inside omp with a bare + # TypeError. Positional alignment forbids skipping, so it must + # refuse loudly, naming the offending feature. + import pytest + + from mcp_server.shared.types_features import Feature, FeatureDictionary + + broken = FeatureDictionary( + K=1, + D=27, + sparsity=2, + signalNames=[], + learnedFromSessions=0, + features=[ + Feature( + index=0, + label="no-direction", + description="", + topSignals=[], + direction=None, + ) + ], + ) + with pytest.raises(ValueError, match="no-direction"): + encode_session({"toolCounts": {}}, broken) diff --git a/tests_py/core/test_style_classifier.py b/tests_py/core/test_style_classifier.py index cc8ca9fa..e5562d09 100644 --- a/tests_py/core/test_style_classifier.py +++ b/tests_py/core/test_style_classifier.py @@ -148,6 +148,11 @@ def test_breadth_first_exploration(self): class TestUpdateStyleEMA: + def test_both_none_returns_empty_dict(self): + # Regression (#197 type burn-down): (None, None) used to return None + # while the signature promised dict[str, Any]. + assert update_style_ema(None, None) == {} + def test_none_old_returns_new(self): obs = { "activeReflective": 0.5, diff --git a/tests_py/handlers/test_forgetting_cycle.py b/tests_py/handlers/test_forgetting_cycle.py index 400af7b6..5aeb48da 100644 --- a/tests_py/handlers/test_forgetting_cycle.py +++ b/tests_py/handlers/test_forgetting_cycle.py @@ -224,3 +224,22 @@ def test_ablation_short_circuits_cycle(monkeypatch): out = run_forgetting_cycle(_FakeStore([(0.9, 1.0)]), set()) assert out["ablated"] is True assert out["scanned"] == 0 + + +def test_memory_without_created_at_has_no_newer_neighbors(): + """Regression (#197 type burn-down): a row missing created_at used to send + None into the store's ``after`` parameter (an implicit SQL NULL-comparison + no-op on PG, a type error on the SQLite parity path). The contract is now + explicit: no timestamp -> no newer neighbors -> no interference signal — + and the store is never queried with a None bound.""" + + class _ExplodingStore(_FakeStore): + def search_newer_neighbors(self, embedding, after, exclude_id, top_k=10): + raise AssertionError("store must not be queried without created_at") + + store = _ExplodingStore([]) + mem = _memory(created_at=None) + effect = _evaluate_memory(store, mem, recently_active_ids=set()) + assert effect == "retain" + # The leaky integrator still advances (leak-down) and is persisted. + assert 1 in store.accum_writes diff --git a/tests_py/infrastructure/test_pipeline_discovery.py b/tests_py/infrastructure/test_pipeline_discovery.py index bfe26943..6760fb27 100644 --- a/tests_py/infrastructure/test_pipeline_discovery.py +++ b/tests_py/infrastructure/test_pipeline_discovery.py @@ -474,3 +474,30 @@ def test_install_failure_in_ensure_does_not_crash(self, tmp_path, monkeypatch): result = pipeline_discovery.ensure_pipeline_connection() assert result["action"] == "no_pipeline_found" assert not config_path.exists() + + +class TestInstallToolchainCargoGuard: + """Regression (#197 type burn-down): a rust_install result whose action + claims success but carries no usable cargo path used to flow ``None`` + into the build argv; it must be refused as missing_toolchain.""" + + def test_success_action_without_cargo_path_is_missing_toolchain( + self, monkeypatch, tmp_path + ): + from mcp_server.infrastructure import pipeline_installer as pi + + monkeypatch.setattr(pi, "_binary_is_usable", lambda _p: False) + monkeypatch.setattr(pi, "resolve_cargo", lambda: None) + monkeypatch.setattr( + pi, + "install_rust_toolchain", + lambda: {"action": "rust_installed"}, # no "cargo" key + ) + # Prebuilt fast path must not short-circuit the guard under test. + monkeypatch.setattr( + pi, "try_install_prebuilt", lambda _dest: {"action": "skipped"} + ) + out = pi._install_locked(force_rebuild=False, git_url=None) + assert out["action"] == "missing_toolchain" + assert out["missing"] == ["cargo"] + assert out["rust_install_action"] == "rust_installed" diff --git a/tests_py/infrastructure/test_sqlite_parity_197.py b/tests_py/infrastructure/test_sqlite_parity_197.py new file mode 100644 index 00000000..475f11b5 --- /dev/null +++ b/tests_py/infrastructure/test_sqlite_parity_197.py @@ -0,0 +1,218 @@ +"""SQLite store parity surface added for issue #197 (pyright burn-down). + +Every method here previously raised ``AttributeError`` on the SQLite +backend; broad stage boundaries swallowed the error into silent +degradation (the FlashRank shape). These tests pin the parity contract +against a real in-memory store — no mocks on the store side — plus the +two cursor-contract fixes the same change introduced +(``MaterializedCursor.one`` and the compat ``executemany``). +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta, timezone + +import numpy as np +import pytest + +from mcp_server.infrastructure.pg_store_host import MaterializedCursor +from mcp_server.infrastructure.sqlite_compat import PsycopgCompatConnection +from mcp_server.infrastructure.sqlite_store import SqliteMemoryStore + + +def _vec(*values: float) -> bytes: + """A 384-dim float32 blob (the store's vec0 table dimension), zero-padded.""" + full = np.zeros(384, dtype=np.float32) + full[: len(values)] = values + return full.tobytes() + + +def _mem(content: str, **overrides) -> dict: + data = {"content": content, "tags": [], "embedding": None} + data.update(overrides) + return data + + +@pytest.fixture() +def store() -> SqliteMemoryStore: + return SqliteMemoryStore(db_path=":memory:", embedding_dim=384) + + +class TestAcquireAndExecuteParity: + def test_acquire_interactive_yields_the_compat_connection(self, store): + with store.acquire_interactive() as conn: + assert conn is store._conn + + def test_acquire_batch_yields_the_compat_connection(self, store): + with store.acquire_batch() as conn: + assert conn is store._conn + + def test_execute_translates_pg_placeholders(self, store): + mid = store.insert_memory(_mem("exec parity")) + rows = store._execute( + "SELECT id FROM memories WHERE id = %s", (mid,) + ).fetchall() + assert [r["id"] for r in rows] == [mid] + + def test_execute_surfaces_untranslatable_sql(self, store): + # jsonb containment has no SQLite translation: the documented + # degraded mode is an OperationalError the caller's mechanism + # boundary observes — never an AttributeError before the query runs. + with pytest.raises(sqlite3.OperationalError): + store._execute("SELECT id FROM memories WHERE tags @> %s::jsonb", ("[]",)) + + +class TestGetMemoriesByTag: + def test_containment_recency_and_limit(self, store): + first = store.insert_memory( + _mem("a", tags=["x-tag"], created_at="2026-01-01T00:00:00+00:00") + ) + second = store.insert_memory( + _mem("b", tags=["x-tag", "other"], created_at="2026-02-01T00:00:00+00:00") + ) + store.insert_memory( + _mem("c", tags=["other"], created_at="2026-03-01T00:00:00+00:00") + ) + rows = store.get_memories_by_tag("x-tag") + assert [r["id"] for r in rows] == [second, first] + assert [r["id"] for r in store.get_memories_by_tag("x-tag", limit=1)] == [ + second + ] + + def test_stale_rows_are_excluded(self, store): + mid = store.insert_memory(_mem("stale", tags=["x-tag"])) + store._conn.execute("UPDATE memories SET is_stale = 1 WHERE id = ?", (mid,)) + store._conn.commit() + assert store.get_memories_by_tag("x-tag") == [] + + def test_no_substring_false_positive(self, store): + store.insert_memory(_mem("sub", tags=["x-tag-suffix"])) + assert store.get_memories_by_tag("x-tag") == [] + + +class TestIterMemoriesForDecay: + def test_single_chunk_matches_get_all(self, store): + ids = {store.insert_memory(_mem(f"m{i}")) for i in range(3)} + chunks = list(store.iter_memories_for_decay()) + assert len(chunks) == 1 + assert {m["id"] for m in chunks[0]} == ids + assert {m["id"] for m in store.get_all_memories_for_decay()} == ids + + +class TestFindCoAccessedPairs: + def test_pairs_are_sorted_and_deduplicated(self, store): + m1 = store.insert_memory(_mem("m1")) + m2 = store.insert_memory(_mem("m2")) + e1 = store.insert_entity({"name": "alpha", "type": "concept"}) + e2 = store.insert_entity({"name": "beta", "type": "concept"}) + e3 = store.insert_entity({"name": "gamma", "type": "concept"}) + for eid in (e2, e1): # insertion order must not matter + store.insert_memory_entity(m1, eid) + store.insert_memory_entity(m2, e2) + store.insert_memory_entity(m2, e3) + pairs = store.find_co_accessed_pairs([m1, m2]) + assert sorted(pairs) == [(min(e1, e2), max(e1, e2)), (min(e2, e3), max(e2, e3))] + + def test_empty_input_is_empty(self, store): + assert store.find_co_accessed_pairs([]) == [] + + +class TestSearchNewerNeighbors: + def test_newer_only_excludes_self_and_orders_by_similarity(self, store): + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + target = store.insert_memory( + _mem("t", embedding=_vec(1, 0, 0), created_at=base.isoformat()) + ) + # Older row: must never appear. + store.insert_memory( + _mem( + "old", + embedding=_vec(1, 0, 0), + created_at=(base - timedelta(days=1)).isoformat(), + ) + ) + store.insert_memory( + _mem( + "near", + embedding=_vec(1, 0.1, 0), + created_at=(base + timedelta(days=1)).isoformat(), + ) + ) + store.insert_memory( + _mem( + "far", + embedding=_vec(0, 1, 0), + created_at=(base + timedelta(days=1)).isoformat(), + ) + ) + result = store.search_newer_neighbors( + _vec(1, 0, 0), base.isoformat(), target, top_k=10 + ) + assert len(result) == 2 + similarities = [sim for sim, _age in result] + assert similarities == sorted(similarities, reverse=True) + assert similarities[0] > 0.9 + assert all(age >= 0 for _sim, age in result) + + def test_zero_query_vector_yields_no_neighbors(self, store): + assert store.search_newer_neighbors(_vec(0, 0, 0), "2026-01-01", 1) == [] + + +class TestUpdateForgettingPressureAccum: + def test_accumulator_is_persisted(self, store): + mid = store.insert_memory(_mem("p")) + store.update_forgetting_pressure_accum(mid, 0.73) + row = store._conn.execute( + "SELECT forgetting_pressure_accum FROM memories WHERE id = ?", (mid,) + ).fetchone() + assert row is not None + assert row["forgetting_pressure_accum"] == pytest.approx(0.73) + + +class TestCompatExecutemany: + def test_executemany_translates_and_inserts_all_rows(self): + raw = sqlite3.connect(":memory:") + raw.row_factory = sqlite3.Row + conn = PsycopgCompatConnection(raw) + with conn.cursor() as cur: + cur.execute("CREATE TABLE t (a INTEGER, b TEXT)") + cur.executemany( + "INSERT INTO t (a, b) VALUES (%s, %s)", [(1, "x"), (2, "y")] + ) + rows = conn.execute("SELECT a, b FROM t ORDER BY a").fetchall() + assert [(r["a"], r["b"]) for r in rows] == [(1, "x"), (2, "y")] + + +class TestInsertLastrowidContract: + def test_missing_lastrowid_raises_with_the_cause(self, store, monkeypatch): + class _NoRowidCursor: + lastrowid = None + rowcount = 1 + + monkeypatch.setattr( + store._conn, + "execute", + lambda *a, **k: _NoRowidCursor(), + ) + with pytest.raises(sqlite3.ProgrammingError, match="no lastrowid"): + store._insert_memory_rows(_mem("broken")) + + +class TestMaterializedCursorOne: + class _FakeCursor: + def __init__(self, rows): + self.rowcount = len(rows) + self._rows = rows + + def fetchall(self): + return self._rows + + def test_one_returns_the_row(self): + cur = MaterializedCursor(self._FakeCursor([{"id": 7}])) + assert cur.one() == {"id": 7} + + def test_one_raises_on_empty_result(self): + cur = MaterializedCursor(self._FakeCursor([])) + with pytest.raises(Exception, match="guaranteed a row"): + cur.one() From 2438267088fbcdd4160d5fcfcd9089167847213a Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 15:35:32 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat(ci):=20retire=20the=20pyright=20ratche?= =?UTF-8?q?t=20=E2=80=94=20ANY=20diagnostic=20now=20fails=20the=20build;?= =?UTF-8?q?=20flip=20warnings=5Fstrict=20to=20Met=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 568-diagnostic backlog is zero, so the ratchet's only purpose (tolerating a backlog without letting it grow) is gone: - typecheck-baseline.json and scripts/check_pyright_ratchet.py deleted; the CI typecheck job runs `pyright mcp_server/` and fails on ANY diagnostic via pyright's own exit code. The type-check env adds the [otel] extra so the exporter imports resolve (their absence was 4 spurious reportMissingImports). - .bestpractices.json: warnings_strict -> Met, citing configuration, not promise: the explicit ruff select list (E4/E7/E9/F/S110/BLE001/PLR2004/E501/PLC0415/S608), pyright standard mode, and the measured zero-diagnostic count (2026-07-28). coding_standards_enforced updated off the retired ratchet. - docs/ASSURANCE-CASE.md: the warnings-strictness caveat is replaced by the factual posture (zero at standard; strict remains an annotation-coverage project, 10,231 errors measured). - CONTRIBUTING.md, docs/provenance/pyright-remediation-plan.md: ratchet references closed out; the plan is marked historical. - One narrowing fix (context_assembly/coverage.py): bind the candidate embedding before branching — variable-index subscripts do not narrow across statements, and the un-narrowed Optional reached np.dot. - Mutation run on the new sqlite parity queries (scoped, scripts/mutation_check.sh): 0 surviving non-equivalent mutants — the one real survivor (default limit 20->21) is now pinned by test_default_limit_caps_at_twenty; the 21 documented equivalents are SQL keyword/identifier case mutants (SQLite is case-insensitive there) and the deliberately-unused chunk_size signature-parity default. - Advertised test count synced 6275 -> 6297 everywhere the doc-claim gate checks; CHANGELOG entry added. Closes the acceptance criteria of #197. Co-Authored-By: Claude Fable 5 --- .bestpractices.json | 14 +- .github/workflows/ci.yml | 30 ++--- CHANGELOG.md | 1 + CLAUDE.md | 2 +- CONTRIBUTING.md | 17 ++- README.md | 4 +- docs/ASSURANCE-CASE.md | 17 ++- docs/provenance/pyright-remediation-plan.md | 8 ++ mcp_server/core/context_assembly/coverage.py | 4 +- mcp_server/infrastructure/sqlite_store.py | 21 ++- scripts/check_pyright_ratchet.py | 125 ------------------ .../infrastructure/test_sqlite_parity_197.py | 9 ++ typecheck-baseline.json | 10 -- 13 files changed, 79 insertions(+), 183 deletions(-) delete mode 100644 scripts/check_pyright_ratchet.py delete mode 100644 typecheck-baseline.json diff --git a/.bestpractices.json b/.bestpractices.json index c752696e..b592889e 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -99,7 +99,7 @@ "build_floss_tools_justification": "pip, uv, hatchling, pytest, ruff and pyright are all FLOSS, and the build runs on ubuntu-latest and windows-latest GitHub runners.", "test_status": "Met", - "test_justification": "An automated test suite of 6275 tests lives under tests_py/ (measured 2026-07-28 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py", + "test_justification": "An automated test suite of 6297 tests lives under tests_py/ (measured 2026-07-28 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py", "test_invocation_status": "Met", "test_invocation_justification": "The whole suite runs with a single 'pytest' command, documented in CONTRIBUTING.md under Testing along with per-layer subsets: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md", @@ -114,7 +114,7 @@ "test_policy_justification": "CONTRIBUTING.md carries an explicit, mandatory testing policy: new functionality ships with tests in the automated suite, a bug fix carries a regression test that fails on the pre-fix code, and each failure path asserts its observable effect including the signal it emits \u2014 https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory. The per-tool checklist ('Add a unit test', 'Add an integration test if the tool touches the database') and the five-element mechanism checklist restate it per change type.", "tests_are_added_status": "Met", - "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6275 tests alongside the v4.x feature series, and CI runs it on every pull request.", + "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6297 tests alongside the v4.x feature series, and CI runs it on every pull request.", "tests_documented_added_status": "Met", "tests_documented_added_justification": "The requirement is written into the documented instructions for change proposals: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory states that every change adding or altering observable behaviour must arrive with tests in the same PR, the per-tool and per-mechanism checklists repeat it as a concrete step, and .github/PULL_REQUEST_TEMPLATE.md requires a Test plan section in every pull request.", @@ -125,8 +125,8 @@ "warnings_fixed_status": "Met", "warnings_fixed_justification": "The lint job fails the build on any ruff finding, so warnings cannot accumulate on main; the typecheck job enforces a ratchet that fails if the pyright diagnostic count rises above its floor.", - "warnings_strict_status": "Unmet", - "warnings_strict_justification": "Not yet maximally strict, and reported as such. Ruff runs its default rule set rather than a broad selection (a run with `--select ALL` minus docstring/annotation/comma rules reports 3,545 findings, measured 2026-07-27), and pyright runs typeCheckingMode `basic` behind a per-rule ratchet whose committed floors still total 568 diagnostics. Both gates do block regression today \u2014 CI fails on any ruff finding and on any rule exceeding its floor \u2014 so warnings cannot accumulate, but the strictness level itself is not maximal. Tightening it rule by rule is tracked with acceptance criteria in https://github.com/cdeust/Cortex/issues/197 and is item 1 on the roadmap.", + "warnings_strict_status": "Met", + "warnings_strict_justification": "Maximally strict, by configuration rather than promise. Ruff runs an explicit broadened rule set \u2014 select = [E4, E7, E9, F, S110, BLE001, PLR2004, E501, PLC0415, S608] (pyproject.toml [tool.ruff.lint]) \u2014 with every production finding fixed or carrying a per-site noqa naming its mechanism, and CI fails on any finding. Pyright (pinned 1.1.410) runs typeCheckingMode `standard` over mcp_server/ with ZERO diagnostics (568-diagnostic ratchet backlog burned to zero, measured 2026-07-28, issue #197); the per-rule ratchet is retired and CI fails on ANY pyright diagnostic via its exit code. `strict` mode is not practical: it reports 10,231 errors, ~9,300 of them the Unknown-type annotation-coverage family (measured 2026-07-27). See .github/workflows/ci.yml jobs `lint` and `typecheck`.", "know_secure_design_status": "Met", "know_secure_design_justification": "The design is local-first with an explicitly documented trust boundary: PRIVACY.md states what is processed, where it is stored, and precisely what leaves the machine (model downloads only, opt-in OTLP), and SECURITY.md documents what Cortex accesses and its supply-chain assurance: https://github.com/cdeust/Cortex/blob/main/PRIVACY.md", @@ -189,7 +189,7 @@ "static_analysis_often_justification": "CodeQL default setup analyses each push and pull request and additionally runs on a weekly schedule, so analysis happens per change rather than per release.", "dynamic_analysis_status": "Unmet", - "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6275-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.", + "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6297-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.", "dynamic_analysis_unsafe_status": "N/A", "dynamic_analysis_unsafe_justification": "Cortex is written in Python, a memory-safe language, so the memory-safety tooling this criterion asks about (ASan, Valgrind) does not apply.", @@ -261,7 +261,7 @@ "coding_standards_justification": "https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#coding-standards-excerpt names the standard for the primary language (Python): ruff formatting and linting as the style baseline, plus the project's own load-bearing rules (no `Any` in production code, sourced constants, no bare `except`, file and function size limits), with the full text at https://github.com/cdeust/zetetic-team-subagents/blob/main/rules/coding-standards.md. Compliance is required of contributions, not suggested.", "coding_standards_enforced_status": "Met", - "coding_standards_enforced_justification": "Automatically enforced in CI on every push and pull request: `ruff format --check .` and `ruff check .` with ruff pinned at 0.15.20, plus a pyright per-rule ratchet (scripts/check_pyright_ratchet.py) that fails the build if any rule's diagnostic count rises above its committed floor. Style exceptions are per-line `# noqa` with the rule code named at the site. See .github/workflows/ci.yml (jobs `lint` and `typecheck`).", + "coding_standards_enforced_justification": "Automatically enforced in CI on every push and pull request: `ruff format --check .` and `ruff check .` with ruff pinned at 0.15.20, plus pyright (pinned 1.1.410, typeCheckingMode `standard`) failing the build on any diagnostic over mcp_server/ (zero-diagnostic tree since issue #197, 2026-07-28). Style exceptions are per-line `# noqa` with the rule code named at the site. See .github/workflows/ci.yml (jobs `lint` and `typecheck`).", "build_standard_variables_status": "N/A", "build_standard_variables_justification": "No native binaries are produced. Cortex is pure Python packaged with hatchling; there is no compiler or linker invocation for CC/CFLAGS/LDFLAGS to reach.", @@ -297,7 +297,7 @@ "interfaces_current_justification": "The stack targets currently supported runtimes and APIs: Python 3.10 through 3.13, all four in the CI matrix, with pgvector 0.3+/PostgreSQL 17 and current major versions of FastMCP, Pydantic v2, numpy and sentence-transformers. Deprecated interfaces are removed rather than wrapped \u2014 the standing rule is one-shot migrations with no back-compat shims (CLAUDE.md), and CI runs on the newest released Python so a deprecation surfaces as a warning in the build rather than as a surprise at end of life.", "automated_integration_testing_status": "Met", - "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6275 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.", + "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6297 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.", "regression_tests_added50_status": "Met", "regression_tests_added50_justification": "Measured on 2026-07-27 over the merged pull requests titled as fixes in the preceding six months (2026-01-27 onward): 23 of 30 \u2014 76.7% \u2014 changed the test tree in the same PR, against the 50% this criterion asks for. (The measure counts a PR as carrying tests when it touches tests_py/ or tests_js/, which is a proxy for 'added a regression test'; the policy behind it is written down at https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory \u2014 a bug fix carries a regression test that fails on the pre-fix code.)", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eda73607..1b82e969 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -381,30 +381,28 @@ jobs: # Pyright resolves third-party imports from ./.venv (pinned in # pyrightconfig.json: venvPath="."/venv=".venv"). The full stub set MUST # be installed or every import collapses to Unknown — and Unknown - # SUPPRESSES downstream type errors, making the baseline meaningless. + # SUPPRESSES downstream type errors, silently masking real defects. # source: measured 2026-06-18 — an unresolved env reports 566 errors, a # fully-resolved env reports 593 (Unknown was masking 27+ real errors). - # flashrank (core reranker) + sqlite-vec live outside dev/postgresql/codebase. + # flashrank (core reranker) + sqlite-vec live outside dev/postgresql/ + # codebase; [otel] resolves the opentelemetry exporter imports. - name: Create .venv with the full type-check environment run: | python -m venv .venv .venv/bin/pip install -U pip - .venv/bin/pip install -e ".[dev,postgresql,sqlite,codebase]" flashrank - # Pin pyright — diagnostic output drifts between releases, so the - # committed baseline is only comparable against the pinned version. + .venv/bin/pip install -e ".[dev,postgresql,sqlite,codebase,otel]" flashrank + # Pin pyright — diagnostic output drifts between releases, so a + # zero-diagnostic tree is only comparable against the pinned version. .venv/bin/pip install pyright==1.1.410 - # The ratchet IS the gate. pyright always exits non-zero while the - # 568-error backlog stands, so its own exit is ignored (|| true); the - # build fails only when a --blocking rule regresses past its committed - # baseline (0). Add rules to --blocking as each batch drives them to - # their floor — see docs/provenance/pyright-remediation-plan.md. - - name: Run pyright + per-rule ratchet gate - run: | - .venv/bin/python -m pyright --outputjson mcp_server/ > pyright-current.json || true - .venv/bin/python scripts/check_pyright_ratchet.py \ - pyright-current.json typecheck-baseline.json \ - --blocking reportOptionalMemberAccess reportOptionalSubscript + # pyright IS the gate: the backlog was burned to zero (issue #197, + # 568 baselined diagnostics -> 0 measured 2026-07-28), so ANY + # diagnostic — any rule, any severity-error — fails the build via + # pyright's own exit code. The former per-rule ratchet + # (scripts/check_pyright_ratchet.py + typecheck-baseline.json) is + # retired: a floor file only exists to tolerate a backlog. + - name: Run pyright (zero-diagnostic gate) + run: .venv/bin/python -m pyright mcp_server/ build: name: Build Package diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c825ab..3776934f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **Pyright is now a zero-diagnostic blocking gate** (#197, final family of the maximal-strictness program). The 568-diagnostic per-rule ratchet backlog was burned to **zero** at `typeCheckingMode: "standard"` (pyright 1.1.410, measured 2026-07-28): no rule disabled, no floor raised; the single per-site suppression is the unpublished optional `cortex_beam_abstain` import whose `except ImportError` arm is the documented degraded mode. The ratchet machinery (`typecheck-baseline.json` + `scripts/check_pyright_ratchet.py`) is retired — CI fails on ANY diagnostic via pyright's own exit code, and the CI type-check env installs the `[otel]` extra so the exporter imports resolve. The burn-down was fixes, not annotations-to-match: a typed host contract for the eight `PgMemoryStore` mixins (`pg_store_host.PgStoreHost` + `MaterializedCursor`, whose honest `DictRow` typing surfaced ten unchecked `INSERT..RETURNING` sites, now `one()` with a real error), a cross-backend `StoreConnection` union for the 16 shared query modules (the psycopg-only annotation had switched checking off for every SQLite call path), and **SQLite store parity for eight methods callers already used unconditionally** — `acquire_interactive`/`acquire_batch`, `_execute`, `search_newer_neighbors`, `update_forgetting_pressure_accum`, `get_memories_by_tag`, `iter_memories_for_decay`, `find_co_accessed_pairs` — each of which previously raised `AttributeError` on the SQLite backend and was swallowed into silent degradation by broad stage boundaries. Latent bugs fixed en route, each with a regression test: the compat cursor lacked `executemany` (SQLite wiki page-sources writes crashed), `lastrowid` honesty (insert paths now raise on a broken row-id contract instead of masking it with a stale `type: ignore`), the pipeline installer accepted a success result carrying no cargo path (None flowed into the build argv), `update_style_ema(None, None)` returned `None` against a `dict` signature, `encode_session` died with a bare `TypeError` on a direction-less feature (now refuses loudly, naming it), `get_causal_chain` could return `reason=None`, and active forgetting sent a `None` timestamp into the store. `.bestpractices.json` flips `warnings_strict` to **Met**, citing the ruff select list, the pyright mode, and the measured zero. Suite grows 6275 → 6297. - **ruff `PLC0415` (import-outside-top-level) and `S608` (string-built SQL) are now blocking lint gates** (#197, fourth rule family of the maximal-strictness program). All 520 production `PLC0415` findings (407 `mcp_server/`, 57 `benchmarks/`, 56 `scripts/`) were triaged one by one: **360 lazy imports moved to module top** — so the import graph is static and a broken module fails at boot, not mid-operation — and the 160 that remain each carry a per-site `# noqa: PLC0415 — ` naming one of six sanctioned justifications: an optional dependency behind an extra, an internal module whose top-level closure hard-imports one (hoisting would break `[sqlite]`-only installs at import time), an ImportError-probe boundary where the except arm IS the degraded mode, an import cycle (partner named; the pre-existing #233 family), the hook latency boundary (per-event hook processes boot in ~0.05 s vs ~0.6 s for the registry closure, measured 2026-07-28 — hoisting the handler/store stack into a hook would multiply every hook event's cost), or a deferral the module itself documents. The hoist is behavior-preserving: the per-module import sweep matches the pre-change baseline exactly (515 modules, the same 6 pre-existing cycle failures), and warm import timings are unchanged. All 44 production `S608` sites carry a per-site `# noqa: S608 — ` naming the exact reason the interpolation is safe (two-literal ternaries, generated placeholder lists, module-level `WHERE` literals, or allowlist-gated identifiers per `docs/ASSURANCE-CASE.md` §5), so any NEW string-built SQL fails CI until it states its mechanism. `tests_py/**` adds both rules to its written per-file ignore (function-level imports in tests are the fixture mechanism; SQL built in tests is fixture setup against a throwaway database). - **ruff `PLR2004` (magic-value comparison) and `E501` (line-too-long) are now blocking lint gates** (#197, third rule family of the maximal-strictness program). All 420 production `PLR2004` findings (339 `mcp_server/`, 57 `benchmarks/`, 14 `scripts/`, 10 `video/`) were fixed with **zero `# noqa: PLR2004`**: every compared literal became a named constant carrying a `# source:` comment — a real citation where the module documents one (Frey & Morris 1997 / Kandel 2001 / Tse 2007 cascade thresholds, RFC 9110 status bands, FIPS 180-4 digest lengths, issue-quoted gates), a structural rationale for arities (split-parts, tuple lengths), and an explicit `pre-existing tuned value, extracted unchanged; provenance not recorded at introduction` where none is discoverable — never an invented source. `tests_py/**` carries a written per-file ignore (the compared literal in an assertion IS the expected value under test — the spec itself). All 470 `E501` findings (263 `mcp_server/`, 121 `tests_py/`, 48 `benchmarks/`, 37 `scripts/`, 1 `_pipeline`) were fixed by **rewrapping at the unchanged 88-column formatter limit** — string content kept byte-identical via implicit concatenation at existing whitespace (SQL and regex literals machine-verified byte-for-byte) — with exactly two per-site `# noqa: E501 — ` for unsplittable absolute-path tokens; E501 has **no** tests ignore. Two drift risks were closed at the source: `handlers/consolidation/transfer.py` re-declared as bare literals the canonical constants of `core/two_stage_transfer.py` (whose own comment forbids redefinition) and now imports them; `benchmarks/beam/ablation.py`'s copies are named per-module without value drift. - **ruff `BLE001` (blind-except) is now a blocking lint gate** (#197, second rule family of the maximal-strictness program). All 351 broad `except Exception` sites were triaged one by one, none blanket-ignored: (a) sites whose failure class is precisely known were **narrowed to typed excepts** — `json.loads` tag decoders to `ValueError`, lazy imports to `ImportError`, `subprocess` probes to `(OSError, SubprocessError)`, SQLite store guards to `sqlite3.Error`, PG connection/read guards to `psycopg.Error`, URL probes to `(OSError, ValueError, HTTPException)`, file I/O to `OSError` — so an unexpected programming error now **propagates instead of being absorbed** by a tolerant fallback; (b) genuine last-resort boundaries (degraded-mechanism wrappers, per-item batch isolation, hook/CLI entry points, diagnostic probes) stay broad and each carries a per-site `# noqa: BLE001 — ` naming the signal it emits; (c) ~50 previously **silent** broad handlers now emit an observable signal — `silent_failure.note()` under 38 new stable component names (spreading-activation, wiki classifier user rules, candidate scans, memify reweight/derive, ingest tag lookups, prospective-trigger injection, source attribution, wiki pointer memories, AP-bridge/groomer config reads, …) or the hook log (`session_start` banner fetches, cached-graph lookups); (d) `mcp_client` connection failures re-raise with `from e`, preserving the causal chain. `tests_py/**` keeps a written per-file ignore (broad excepts in tests are deliberate teardown/optional-path handling). Every new signal is asserted by a test (54 added). diff --git a/CLAUDE.md b/CLAUDE.md index ac4deee8..53bb0693 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ coding write gates, causal graphs, and intent-aware retrieval. - Install (dev): `uv pip install -e ".[dev]"` — SQLite backend: `".[dev,sqlite]"` - Environment preflight: `python -m mcp_server.doctor` (backend-aware check list, fix message per check) -- Tests: `pytest` (full suite, 6275 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing` +- Tests: `pytest` (full suite, 6297 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing` - Lint BEFORE every commit: `ruff check && ruff format --check` — the CI enforces **both**; passing only `ruff check` is not enough. - Release gate benchmarks (isolated, ephemeral container — the only source of truth for pre-tag/floor decisions): `benchmarks/reproduce.sh`. Do NOT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e18aafb9..e9204419 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ bash scripts/setup.sh # macOS / Linux # Verify everything is wired uvx --python 3.13 --from "hypermnesia-mcp[postgresql]" cortex-doctor -# Run tests (6275 tests under tests_py/) +# Run tests (6297 tests under tests_py/) pytest # Run a benchmark @@ -113,11 +113,10 @@ project-specific rules: - **No mutable default arguments.** No globals except for read-once configuration objects. - **No bare `except:`.** Catch the specific exception you mean. -- **Type-checked under a ratchet.** `pyright` (pinned 1.1.410) runs over - `mcp_server/` and `scripts/check_pyright_ratchet.py` fails the build if any - rule's diagnostic count rises above its floor in - [`typecheck-baseline.json`](typecheck-baseline.json). New code must not add - diagnostics; the floors only come down. Remediation plan: +- **Type-checked at zero.** `pyright` (pinned 1.1.410, `typeCheckingMode: + "standard"`) runs over `mcp_server/` and ANY diagnostic fails the build — + the 568-diagnostic ratchet backlog was burned to zero in issue #197 + (2026-07-28) and the ratchet retired. History: [`docs/provenance/pyright-remediation-plan.md`](docs/provenance/pyright-remediation-plan.md). - **§4.1 File ≤500 lines, §4.2 function ≤50 lines.** @@ -129,7 +128,7 @@ The full standard lives in ## Testing ```bash -pytest # full suite (6275 tests) +pytest # full suite (6297 tests) pytest tests_py/core # core (pure business logic) only pytest tests_py/integration # PostgreSQL-backed integration pytest tests_py/benchmarks -k locomo # subset @@ -187,8 +186,8 @@ mechanically for the modules it is scoped to. is not a citation. - Don't introduce a heavy ML model dependency that breaks the runs-on-your-machine guarantee. -- Don't raise a pyright ratchet floor to make the build pass. The type system - is the contract; the floors only come down. +- Don't suppress a pyright diagnostic to make the build pass. The type + system is the contract; the tree stays at zero diagnostics. - Don't relax a test that fails on your branch. The test exists for a reason; understand the reason before changing it. diff --git a/README.md b/README.md index aa237450..843dca83 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ CI MIT License Python 3.10+ - 6275 passing + 6297 passing References Version 4.16.0 OpenSSF Best Practices @@ -525,7 +525,7 @@ Cortex is **local-first**: your memories, conversations, and profiles stay on yo ## Development ```bash -pytest # 6275 tests +pytest # 6297 tests ruff check . # Lint ruff format --check . # Format python scripts/check_doc_claims.py # advertised counts must match the repo diff --git a/docs/ASSURANCE-CASE.md b/docs/ASSURANCE-CASE.md index e44695b7..8c3a6245 100644 --- a/docs/ASSURANCE-CASE.md +++ b/docs/ASSURANCE-CASE.md @@ -128,7 +128,7 @@ decoratively: Standing analysis: CodeQL default setup (Python, JavaScript/TypeScript, Actions) on every push and pull request plus weekly, currently 0 open alerts; -OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and 6275 tests run on +OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and 6297 tests run on four Python versions, two backends and Windows. ## 6. What this assurance case does NOT claim @@ -150,12 +150,15 @@ four Python versions, two backends and Windows. [#228](https://github.com/cdeust/Cortex/issues/228)). Raising mutation strength across the load-bearing set remains item 1 on [ROADMAP.md](ROADMAP.md). -- **Warnings are not yet maximally strict.** Ruff runs its default rule set, so - the weakness classes a broader rule set would flag are currently invisible to - the build. Pyright runs `standard` (raised from `basic` 2026-07-27) behind a - per-rule ratchet; `strict` remains out of reach — it reports 10,231 errors, - ~9,300 of them the Unknown-type family, which is an annotation-coverage - project rather than a configuration change (measured, issue #197). +- **Pyright `strict` is not the operating mode.** The build type-checks at + `standard` with **zero diagnostics** (568-diagnostic backlog burned down, + issue #197, 2026-07-28) and ruff enforces an explicit, broadened rule set + (`E4/E7/E9/F/S110/BLE001/PLR2004/E501/PLC0415/S608`, each finding fixed or + carrying a per-site named mechanism) — that is the "maximally strict, where + practical" posture. `strict` mode remains out of reach: it reports 10,231 + errors, ~9,300 of them the Unknown-type family, which is an + annotation-coverage project rather than a configuration change (measured, + issue #197). - **No dynamic analysis is performed.** There is no fuzzer and no sanitizer run; the language removes the memory-safety motivation for one, but that is an argument about a class of bug, not a substitute for the technique. diff --git a/docs/provenance/pyright-remediation-plan.md b/docs/provenance/pyright-remediation-plan.md index fe72aecf..8fdefdca 100644 --- a/docs/provenance/pyright-remediation-plan.md +++ b/docs/provenance/pyright-remediation-plan.md @@ -13,6 +13,14 @@ > `sqlite3.Connection`). `strict` reports 10,231 and stays out of scope. > The blocking rules (`reportOptionalMemberAccess`, `reportOptionalSubscript`) > are at 0 and `typecheck-baseline.json` is unchanged — no floor was raised. +> +> **Update 2026-07-28 (issue #197, final).** The backlog is **zero**: all 568 +> baselined diagnostics were fixed (no rule disabled, no floor raised; the +> only per-site suppression is the unpublished optional `cortex_beam_abstain` +> import, whose `except ImportError` arm is the documented degraded mode). +> `typecheck-baseline.json` and `scripts/check_pyright_ratchet.py` are +> retired; CI now fails on ANY pyright diagnostic via pyright's own exit +> code. This document is historical. --- diff --git a/mcp_server/core/context_assembly/coverage.py b/mcp_server/core/context_assembly/coverage.py index 4db61125..a142d319 100644 --- a/mcp_server/core/context_assembly/coverage.py +++ b/mcp_server/core/context_assembly/coverage.py @@ -96,10 +96,10 @@ def submodular_select( continue # Marginal relevance = score - λ * max sim to already-selected - if not selected_embs or embeddings[i] is None: + emb = embeddings[i] + if not selected_embs or emb is None: penalty = 0.0 else: - emb = embeddings[i] sims = [ float( np.dot(emb, s) diff --git a/mcp_server/infrastructure/sqlite_store.py b/mcp_server/infrastructure/sqlite_store.py index bb954125..e7b2f3ce 100644 --- a/mcp_server/infrastructure/sqlite_store.py +++ b/mcp_server/infrastructure/sqlite_store.py @@ -756,22 +756,35 @@ def search_newer_neighbors( ``<=>`` operator, so the candidate rows are scored with the same numpy cosine the fallback vector search uses. """ + if not self._has_vec: + # Without sqlite-vec no vectors are persisted at all, so there is + # no interference signal to compute — same empty result the PG + # query yields for a corpus with NULL embeddings. + return [] query_vec = self._bytes_to_vector(query_embedding) if query_vec is None: return [] q_norm = float(np.linalg.norm(query_vec)) if q_norm == 0.0: return [] + # Embeddings live in the memories_vec virtual table; join client-side + # per the established idiom (sqlite_store_search.get_hot_embeddings): + # fetch candidate rows, then the vector per rowid. rows = self._conn.execute( - "SELECT id, embedding, created_at FROM memories " - "WHERE created_at > ? AND id != ? AND is_stale = 0 " - "AND embedding IS NOT NULL", + "SELECT id, created_at FROM memories " + "WHERE created_at > ? AND id != ? AND is_stale = 0", (after, exclude_id), ).fetchall() now = datetime.now(timezone.utc) scored: list[tuple[float, float]] = [] for r in rows: - vec = self._bytes_to_vector(r["embedding"]) + vec_row = self._conn.execute( + "SELECT embedding FROM memories_vec WHERE rowid = ?", + (int(r["id"]),), + ).fetchone() + if vec_row is None: + continue + vec = self._bytes_to_vector(vec_row["embedding"]) if vec is None or vec.shape != query_vec.shape: continue denom = q_norm * float(np.linalg.norm(vec)) diff --git a/scripts/check_pyright_ratchet.py b/scripts/check_pyright_ratchet.py deleted file mode 100644 index 9d1f68a4..00000000 --- a/scripts/check_pyright_ratchet.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Pyright per-rule ratchet gate. - -Compares current pyright error counts (grouped by rule) against a committed -compact baseline and fails ONLY when a rule listed as ``--blocking`` exceeds -its baseline count. Non-blocking rules are reported but never fail the build, -so the type-check backlog can be burned down rule-by-rule while a rule that -has already reached its floor can never silently regress. - -Why a compact ``{rule: count}`` baseline instead of the raw ``--outputjson``? -The raw dump is ~200 KB of volatile ranges/messages; a per-rule count map is a -few lines, reviewable in a PR diff, and is all the gate actually needs. - -Usage:: - - # regenerate the committed baseline from a fresh run: - pyright --outputjson mcp_server/ > pyright-current.json - python scripts/check_pyright_ratchet.py pyright-current.json \ - typecheck-baseline.json --write-baseline - - # gate (CI): fail if any blocking rule regressed past its baseline: - python scripts/check_pyright_ratchet.py pyright-current.json \ - typecheck-baseline.json \ - --blocking reportOptionalMemberAccess reportOptionalSubscript - -``current`` is the raw output of ``pyright --outputjson``. -``baseline`` is the compact ``{rule: count}`` map this script writes. -""" - -from __future__ import annotations - -import argparse -import json -import sys - - -def count_errors_by_rule(pyright_output: dict) -> dict[str, int]: - """Tally error-severity diagnostics by their pyright rule name.""" - counts: dict[str, int] = {} - for diagnostic in pyright_output.get("generalDiagnostics", []): - if diagnostic.get("severity") != "error": - continue - rule = diagnostic.get("rule", "") - counts[rule] = counts.get(rule, 0) + 1 - return counts - - -def load_json(path: str) -> dict: - with open(path, encoding="utf-8") as handle: - return json.load(handle) - - -def report( - current: dict[str, int], - baseline: dict[str, int], - blocking: set[str], -) -> list[str]: - """Print a per-rule delta table; return the regressed blocking rules.""" - regressed: list[str] = [] - for rule in sorted(set(current) | set(baseline)): - now = current.get(rule, 0) - was = baseline.get(rule, 0) - marker = "" - if rule in blocking and now > was: - marker = " <-- BLOCKING REGRESSION" - regressed.append(rule) - elif now < was: - marker = f" (-{was - now} improved)" - elif now > was: - marker = f" (+{now - was})" - gate = "[blocking]" if rule in blocking else "[tracked] " - print(f" {gate} {rule}: {now} (baseline {was}){marker}") - return regressed - - -def write_baseline(current: dict[str, int], path: str) -> int: - """Overwrite the baseline file from the current counts (sorted, stable).""" - with open(path, "w", encoding="utf-8") as handle: - json.dump(dict(sorted(current.items())), handle, indent=2) - handle.write("\n") - total = sum(current.values()) - print(f"Wrote baseline ({total} errors across {len(current)} rules) -> {path}") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description="Pyright per-rule ratchet gate") - parser.add_argument("current", help="pyright --outputjson output file") - parser.add_argument("baseline", help="compact {rule: count} baseline file") - parser.add_argument( - "--blocking", - nargs="*", - default=[], - help="rules that fail the build when their count exceeds baseline", - ) - parser.add_argument( - "--write-baseline", - action="store_true", - help="overwrite the baseline from the current run and exit 0", - ) - args = parser.parse_args() - - current = count_errors_by_rule(load_json(args.current)) - - if args.write_baseline: - return write_baseline(current, args.baseline) - - try: - baseline = load_json(args.baseline) - except FileNotFoundError: - print(f"FATAL: baseline {args.baseline} not found; run --write-baseline first") - return 2 - - total, baseline_total = sum(current.values()), sum(baseline.values()) - print(f"Pyright ratchet — current {total} errors vs baseline {baseline_total}") - regressed = report(current, baseline, set(args.blocking)) - - if regressed: - print(f"\nFAIL: blocking rule(s) regressed past baseline: {sorted(regressed)}") - return 1 - print("\nPASS: no blocking rule regressed") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests_py/infrastructure/test_sqlite_parity_197.py b/tests_py/infrastructure/test_sqlite_parity_197.py index 475f11b5..4305b585 100644 --- a/tests_py/infrastructure/test_sqlite_parity_197.py +++ b/tests_py/infrastructure/test_sqlite_parity_197.py @@ -80,6 +80,15 @@ def test_containment_recency_and_limit(self, store): second ] + def test_default_limit_caps_at_twenty(self, store): + # Pins the documented default (mutation run 2026-07-28: the 20->21 + # mutant survived every other assertion). + for i in range(21): + store.insert_memory( + _mem(f"m{i}", tags=["cap-tag"], created_at=f"2026-01-{i + 1:02d}") + ) + assert len(store.get_memories_by_tag("cap-tag")) == 20 + def test_stale_rows_are_excluded(self, store): mid = store.insert_memory(_mem("stale", tags=["x-tag"])) store._conn.execute("UPDATE memories SET is_stale = 1 WHERE id = ?", (mid,)) diff --git a/typecheck-baseline.json b/typecheck-baseline.json deleted file mode 100644 index 207199eb..00000000 --- a/typecheck-baseline.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "reportArgumentType": 98, - "reportAssignmentType": 26, - "reportAttributeAccessIssue": 351, - "reportCallIssue": 37, - "reportGeneralTypeIssues": 2, - "reportMissingImports": 1, - "reportOperatorIssue": 1, - "reportReturnType": 52 -} From b1286bc3f3d8303e79403885a4d516e82bee895c Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 15:42:36 +0200 Subject: [PATCH 7/8] docs(lint): written rationale for every ruff family not selected (#197, criterion 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stated exclusion classes in [tool.ruff.lint], each with measured counts (mcp_server/, 2026-07-28): rejected style-police families (D/ANN/COM/TD/I — the checking they promise is owned by review, pyright, the formatter, issue governance, and PLC0415 respectively), rewrite-suggestion families (UP/C4/SIM/PIE/RSE/PERF/RUF — transformations of working code outside this program's defect classes), and the untriaged judgement families (B/TRY/RET/ARG/PTH/N/ERA/FBT/DTZ/S-rest) now tracked as the program's continuation in #239 — a family stays out deliberately or lands with every finding triaged, never by accident. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8b3dd8a1..1986b2da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,6 +201,41 @@ select = [ # any NEW string-built SQL fails CI until it states its mechanism. "S608", ] +# ── Families deliberately NOT selected (issue #197, criterion 1) ──────────── +# Counts measured against mcp_server/ 2026-07-28, ruff 0.15.20 — what +# enabling each would face. The program's bar: a family lands only with every +# finding fixed or per-site-justified, never blanket-ignored. Three stated +# reasons for exclusion: +# +# (a) REJECTED — polices style this project delegates elsewhere: +# D (1,094): docstring FORMATTING conventions (blank lines, imperative +# mood). Docstring content — contracts, pre/postconditions — is +# reviewed under the completion checklist; the formatting rules find +# no defects. +# ANN (347): annotation-PRESENCE checks. The pyright gate holds +# mcp_server/ at zero diagnostics and checks annotation CORRECTNESS; +# ANN would duplicate it textually. +# COM (937): trailing-comma style is owned by the pinned formatter; +# ruff's own documentation recommends against COM alongside +# `ruff format`. +# TD/FIX (0): deferral governance lives in filed issues with acceptance +# criteria, not in TODO-comment metadata shape. +# I (271): import-ORDER cosmetics — repo-wide churn, no defect-finding +# power. Import PLACEMENT, the failure-relevant half, is enforced by +# PLC0415 above. +# +# (b) REWRITE-SUGGESTION families — UP (91), C4 (13), SIM (68), PIE (10), +# RSE (1), PERF (94), RUF (725): stylistic/modernization transformations +# of working code. None targets the silent-failure, injection, or +# contract-lie defect classes this program exists for; each is a +# dedicated-PR-sized diff under the fix-with-landing bar. +# +# (c) JUDGEMENT families not yet triaged — B (16), TRY (163), RET (20), +# ARG (53), PTH (91), N (43), ERA (43), FBT (254), DTZ (4), S beyond +# S110/S608 (34): tracked as the program's continuation in issue #239. +# Enabling any of them without the per-finding triage the first six +# families received would mean blanket ignores — the exact anti-pattern +# this explicit select list exists to prevent. [tool.ruff.lint.per-file-ignores] # Test code: try/except/pass and broad excepts in tests are deliberate From 28cb297ce66af26713a0d7e20cd5a1eb928ccaed Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 28 Jul 2026 16:00:14 +0200 Subject: [PATCH 8/8] test(sqlite): gate the vec-dependent parity class on sqlite-vec availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestSearchNewerNeighbors exercises the vector path, which the PG-matrix CI jobs (and any Python built without extension support) cannot load — they got the documented empty degraded result and failed. Reuses the _needs_vec skip idiom from test_semantic_fallback_169.py; the dedicated SQLite job (extension present) still executes the class for real. Co-Authored-By: Claude Fable 5 --- .../infrastructure/test_sqlite_parity_197.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests_py/infrastructure/test_sqlite_parity_197.py b/tests_py/infrastructure/test_sqlite_parity_197.py index 4305b585..670f08df 100644 --- a/tests_py/infrastructure/test_sqlite_parity_197.py +++ b/tests_py/infrastructure/test_sqlite_parity_197.py @@ -34,6 +34,21 @@ def _mem(content: str, **overrides) -> dict: return data +def _sqlite_vec_available() -> bool: + s = SqliteMemoryStore(db_path=":memory:", embedding_dim=384) + try: + return bool(s._has_vec) + finally: + s.close() + + +_HAS_VEC = _sqlite_vec_available() +_needs_vec = pytest.mark.skipif( + not _HAS_VEC, + reason="sqlite-vec not installed ([sqlite] extra); vector path unavailable", +) + + @pytest.fixture() def store() -> SqliteMemoryStore: return SqliteMemoryStore(db_path=":memory:", embedding_dim=384) @@ -127,6 +142,7 @@ def test_empty_input_is_empty(self, store): assert store.find_co_accessed_pairs([]) == [] +@_needs_vec class TestSearchNewerNeighbors: def test_newer_only_excludes_self_and_orders_by_similarity(self, store): base = datetime(2026, 1, 1, tzinfo=timezone.utc)