Skip to content

Commit 8072449

Browse files
authored
chore: Add fast feedback loop tooling (#538)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 45d3f58 commit 8072449

15 files changed

Lines changed: 775 additions & 397 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
*.py[cod]
22
__pycache__/
33
.pytest_cache/
4+
.testmondata*
45
.coverage
56
htmlcov/
67

AGENTS.md

Lines changed: 408 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 0 additions & 389 deletions
This file was deleted.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,27 +496,41 @@ just test
496496
- `just test-int-postgres` - Run integration tests against Postgres
497497
- `just test-windows` - Run Windows-specific tests (auto-skips on other platforms)
498498
- `just test-benchmark` - Run performance benchmark tests
499+
- `just testmon` - Run tests impacted by recent changes (pytest-testmon)
500+
- `just test-smoke` - Run fast MCP end-to-end smoke test
501+
- `just fast-check` - Run fix/format/typecheck + impacted tests + smoke test
502+
- `just doctor` - Run local file <-> DB consistency checks with temp config
499503

500504
**Postgres Testing:**
501505

502506
Postgres tests use [testcontainers](https://testcontainers-python.readthedocs.io/) which automatically spins up a Postgres instance in Docker. No manual database setup required - just have Docker running.
503507

508+
**Testmon Note:** When no files have changed, `just testmon` may collect 0 tests. That's expected and means no impacted tests were detected.
509+
504510
**Test Markers:**
505511

506512
Tests use pytest markers for selective execution:
507513
- `windows` - Windows-specific database optimizations
508514
- `benchmark` - Performance tests (excluded from default runs)
515+
- `smoke` - Fast MCP end-to-end smoke tests
509516

510517
**Other Development Commands:**
511518
```bash
512519
just install # Install with dev dependencies
513520
just lint # Run linting checks
514521
just typecheck # Run type checking
515522
just format # Format code with ruff
523+
just fast-check # Fast local loop (fix/format/typecheck + testmon + smoke)
524+
just doctor # Local consistency check (temp config)
516525
just check # Run all quality checks
517526
just migration "msg" # Create database migration
518527
```
519528

529+
**Local Consistency Check:**
530+
```bash
531+
basic-memory doctor # Verifies file <-> database sync in a temp project
532+
```
533+
520534
See the [justfile](justfile) for the complete list of development commands.
521535

522536
## License

justfile

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ test-int-postgres:
6262
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int
6363
fi
6464

65+
# Run tests impacted by recent changes (requires pytest-testmon)
66+
testmon *args:
67+
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov --testmon --testmon-forceselect {{args}}
68+
69+
# Run MCP smoke test (fast end-to-end loop)
70+
test-smoke:
71+
BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov -m smoke test-int/mcp/test_smoke_integration.py
72+
73+
# Fast local loop: lint, format, typecheck, impacted tests
74+
fast-check:
75+
just fix
76+
just format
77+
just typecheck
78+
just testmon
79+
just test-smoke
80+
6581
# Reset Postgres test database (drops and recreates schema)
6682
# Useful when Alembic migration state gets out of sync during development
6783
# Uses credentials from docker-compose-postgres.yml
@@ -149,6 +165,18 @@ format:
149165
run-inspector:
150166
npx @modelcontextprotocol/inspector
151167

168+
# Run doctor checks in an isolated temp home/config
169+
doctor:
170+
#!/usr/bin/env bash
171+
set -euo pipefail
172+
TMP_HOME=$(mktemp -d)
173+
TMP_CONFIG=$(mktemp -d)
174+
HOME="$TMP_HOME" \
175+
BASIC_MEMORY_ENV=test \
176+
BASIC_MEMORY_HOME="$TMP_HOME/basic-memory" \
177+
BASIC_MEMORY_CONFIG_DIR="$TMP_CONFIG" \
178+
./.venv/bin/python -m basic_memory.cli.main doctor --local
179+
152180

153181
# Update all dependencies to latest versions
154182
update-deps:

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ markers = [
7171
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
7272
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
7373
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
74+
"smoke: Fast end-to-end smoke tests for MCP flows",
7475
]
7576

7677
[tool.ruff]
@@ -91,6 +92,7 @@ dev = [
9192
"testcontainers[postgres]>=4.0.0",
9293
"psycopg>=3.2.0",
9394
"pyright>=1.1.408",
95+
"pytest-testmon>=2.2.0",
9496
]
9597

9698
[tool.hatch.version]

src/basic_memory/cli/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def app_callback(
5050
# Skip for 'mcp' command - it has its own lifespan that handles initialization
5151
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
5252
# Skip for 'reset' command - it manages its own database lifecycle
53-
skip_init_commands = {"mcp", "status", "sync", "project", "tool", "reset"}
53+
skip_init_commands = {"doctor", "mcp", "status", "sync", "project", "tool", "reset"}
5454
if (
5555
not version
5656
and ctx.invoked_subcommand is not None

src/basic_memory/cli/commands/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""CLI commands for basic-memory."""
22

3-
from . import status, db, import_memory_json, mcp, import_claude_conversations
3+
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
44
from . import import_claude_projects, import_chatgpt, tool, project, format
55

66
__all__ = [
77
"status",
88
"db",
9+
"doctor",
910
"import_memory_json",
1011
"mcp",
1112
"import_claude_conversations",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Doctor command for local consistency checks."""
2+
3+
from __future__ import annotations
4+
5+
import tempfile
6+
import uuid
7+
from pathlib import Path
8+
9+
from loguru import logger
10+
from mcp.server.fastmcp.exceptions import ToolError
11+
from rich.console import Console
12+
import typer
13+
14+
from basic_memory.cli.app import app
15+
from basic_memory.cli.commands.command_utils import run_with_cleanup
16+
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
17+
from basic_memory.markdown.entity_parser import EntityParser
18+
from basic_memory.markdown.markdown_processor import MarkdownProcessor
19+
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
20+
from basic_memory.mcp.async_client import get_client
21+
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient, SearchClient
22+
from basic_memory.mcp.tools.utils import call_post
23+
from basic_memory.schemas.base import Entity
24+
from basic_memory.schemas.project_info import ProjectInfoRequest
25+
from basic_memory.schemas.search import SearchQuery
26+
from basic_memory.schemas import SyncReportResponse
27+
28+
console = Console()
29+
30+
31+
async def run_doctor() -> None:
32+
"""Run local consistency checks for file <-> database flows."""
33+
console.print("[blue]Running Basic Memory doctor checks...[/blue]")
34+
35+
project_name = f"doctor-{uuid.uuid4().hex[:8]}"
36+
api_note_title = "Doctor API Note"
37+
manual_note_title = "Doctor Manual Note"
38+
manual_permalink = "doctor/manual-note"
39+
40+
with tempfile.TemporaryDirectory() as temp_dir:
41+
temp_path = Path(temp_dir)
42+
43+
async with get_client() as client:
44+
project_client = ProjectClient(client)
45+
project_request = ProjectInfoRequest(
46+
name=project_name,
47+
path=str(temp_path),
48+
set_default=False,
49+
)
50+
51+
project_id: str | None = None
52+
53+
try:
54+
status = await project_client.create_project(project_request.model_dump())
55+
if not status.new_project:
56+
raise ValueError("Failed to create doctor project")
57+
project_id = status.new_project.external_id
58+
console.print(f"[green]OK[/green] Created doctor project: {project_name}")
59+
60+
# --- DB -> File: create an entity via API ---
61+
knowledge_client = KnowledgeClient(client, project_id)
62+
api_note = Entity(
63+
title=api_note_title,
64+
directory="doctor",
65+
entity_type="note",
66+
content_type="text/markdown",
67+
content=f"# {api_note_title}\n\n- [note] API to file check",
68+
entity_metadata={"tags": ["doctor"]},
69+
)
70+
api_result = await knowledge_client.create_entity(api_note.model_dump(), fast=False)
71+
72+
api_file = temp_path / api_result.file_path
73+
if not api_file.exists():
74+
raise ValueError(f"API note file missing: {api_result.file_path}")
75+
76+
api_text = api_file.read_text(encoding="utf-8")
77+
if api_note_title not in api_text:
78+
raise ValueError("API note content missing from file")
79+
80+
console.print("[green]OK[/green] API write created file")
81+
82+
# --- File -> DB: write markdown file directly, then sync ---
83+
parser = EntityParser(temp_path)
84+
processor = MarkdownProcessor(parser)
85+
manual_markdown = EntityMarkdown(
86+
frontmatter=EntityFrontmatter(
87+
metadata={
88+
"title": manual_note_title,
89+
"type": "note",
90+
"permalink": manual_permalink,
91+
"tags": ["doctor"],
92+
}
93+
),
94+
content=f"# {manual_note_title}\n\n- [note] File to DB check",
95+
)
96+
97+
manual_path = temp_path / "doctor" / "manual-note.md"
98+
await processor.write_file(manual_path, manual_markdown)
99+
console.print("[green]OK[/green] Manual file written")
100+
101+
sync_response = await call_post(
102+
client,
103+
f"/v2/projects/{project_id}/sync?force_full=true&run_in_background=false",
104+
)
105+
sync_report = SyncReportResponse.model_validate(sync_response.json())
106+
if sync_report.total == 0:
107+
raise ValueError("Sync did not detect any changes")
108+
109+
console.print("[green]OK[/green] Sync indexed manual file")
110+
111+
search_client = SearchClient(client, project_id)
112+
search_query = SearchQuery(title=manual_note_title)
113+
search_results = await search_client.search(
114+
search_query.model_dump(), page=1, page_size=5
115+
)
116+
if not any(result.title == manual_note_title for result in search_results.results):
117+
raise ValueError("Manual note not found in search index")
118+
119+
console.print("[green]OK[/green] Search confirmed manual file")
120+
121+
status_response = await call_post(client, f"/v2/projects/{project_id}/status")
122+
status_report = SyncReportResponse.model_validate(status_response.json())
123+
if status_report.total != 0:
124+
raise ValueError("Project status not clean after sync")
125+
126+
console.print("[green]OK[/green] Status clean after sync")
127+
128+
finally:
129+
if project_id:
130+
await project_client.delete_project(project_id)
131+
132+
console.print("[green]Doctor checks passed.[/green]")
133+
134+
135+
@app.command()
136+
def doctor(
137+
local: bool = typer.Option(
138+
False, "--local", help="Force local API routing (ignore cloud mode)"
139+
),
140+
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
141+
) -> None:
142+
"""Run local consistency checks to verify file/database sync."""
143+
try:
144+
validate_routing_flags(local, cloud)
145+
with force_routing(local=local, cloud=cloud):
146+
run_with_cleanup(run_doctor())
147+
except (ToolError, ValueError) as e:
148+
console.print(f"[red]Doctor failed: {e}[/red]")
149+
raise typer.Exit(code=1)
150+
except Exception as e:
151+
logger.error(f"Doctor failed: {e}")
152+
typer.echo(f"Doctor failed: {e}", err=True)
153+
raise typer.Exit(code=1) # pragma: no cover

0 commit comments

Comments
 (0)