diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5ddfc096..918970bb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,11 +14,12 @@ on: jobs: test: - runs-on: ubuntu-latest strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest] python-version: [ "3.12" ] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -35,10 +36,18 @@ jobs: run: | pip install uv - - name: Install just + - name: Install just (Linux/macOS) + if: runner.os != 'Windows' run: | curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin + - name: Install just (Windows) + if: runner.os == 'Windows' + run: | + # Install just using Chocolatey (pre-installed on GitHub Actions Windows runners) + choco install just --yes + shell: pwsh + - name: Create virtual env run: | uv venv @@ -54,4 +63,4 @@ jobs: - name: Run tests run: | uv pip install pytest pytest-cov - just test + just test \ No newline at end of file diff --git a/create_individual_exhibits.py b/create_individual_exhibits.py index 4c4cea14..04369b82 100644 --- a/create_individual_exhibits.py +++ b/create_individual_exhibits.py @@ -150,7 +150,7 @@ def create_overall_summary_exhibit(data, output_dir): for category, count in sorted(summary['categories'].items()): summary_content += f"- **{category.replace('_', ' ').title()}:** {count} files\n" - summary_content += f""" + summary_content += """ ## Contributor Summary """ @@ -158,7 +158,7 @@ def create_overall_summary_exhibit(data, output_dir): for contrib in summary['top_contributors']: summary_content += f"- **{contrib['name']}** ({contrib['email']}): {contrib['file_count']} files, {contrib['commit_count']} commits\n" - summary_content += f""" + summary_content += """ ## Legal Significance This inventory represents the complete codebase of Basic Memory as licensed from Basic Machines LLC to Basic Memory LLC under the copyright license agreement dated [DATE]. diff --git a/legal_file_inventory.py b/legal_file_inventory.py index c13f51c3..98ca9610 100644 --- a/legal_file_inventory.py +++ b/legal_file_inventory.py @@ -16,13 +16,12 @@ Usage: """ import os -import sys import json import csv import subprocess from pathlib import Path from datetime import datetime -from typing import Dict, List, Set, Optional, Tuple +from typing import Dict, List import argparse import hashlib @@ -419,7 +418,7 @@ def main(): # Print summary stats = generator.get_summary_statistics() - print(f"\n=== Legal File Inventory Complete ===") + print("\n=== Legal File Inventory Complete ===") print(f"Repository: {stats.get('repository_path', 'Unknown')}") print(f"Total files inventoried: {stats.get('total_files', 0)}") print(f"Total contributors identified: {stats.get('total_contributors', 0)}") @@ -427,7 +426,7 @@ def main(): # Show top contributors if 'contributor_file_counts' in stats: - print(f"\nTop 5 contributors by files modified:") + print("\nTop 5 contributors by files modified:") sorted_contributors = sorted( stats['contributor_file_counts'].items(), key=lambda x: x[1], diff --git a/scripts/generate_legal_inventory.py b/scripts/generate_legal_inventory.py index f4c0e942..1366895e 100644 --- a/scripts/generate_legal_inventory.py +++ b/scripts/generate_legal_inventory.py @@ -18,13 +18,11 @@ import argparse import csv import hashlib import json -import os import subprocess -import sys from collections import defaultdict from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List class LegalInventoryGenerator: @@ -477,7 +475,7 @@ def main(): if 'markdown' in formats: generator.export_markdown(output_dir / f'basic_memory_inventory_{timestamp}.md') - print(f"\nLegal inventory generation complete!") + print("\nLegal inventory generation complete!") print(f"Output saved to: {output_dir}") diff --git a/src/basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py b/src/basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py index b282a713..6ba87786 100644 --- a/src/basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +++ b/src/basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py @@ -9,7 +9,6 @@ Create Date: 2025-08-19 22:06:00.000000 from typing import Sequence, Union from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/src/basic_memory/services/directory_service.py b/src/basic_memory/services/directory_service.py index be5aec36..e5a1b669 100644 --- a/src/basic_memory/services/directory_service.py +++ b/src/basic_memory/services/directory_service.py @@ -106,8 +106,15 @@ class DirectoryService: List of DirectoryNode objects matching the criteria """ # Normalize directory path + # Strip ./ prefix if present (handles relative path notation) + if dir_name.startswith("./"): + dir_name = dir_name[2:] # Remove "./" prefix + + # Ensure path starts with "/" if not dir_name.startswith("/"): dir_name = f"/{dir_name}" + + # Remove trailing slashes except for root if dir_name != "/" and dir_name.endswith("/"): dir_name = dir_name.rstrip("/") diff --git a/test-int/mcp/test_list_directory_integration.py b/test-int/mcp/test_list_directory_integration.py index fa8da2e8..f8d35814 100644 --- a/test-int/mcp/test_list_directory_integration.py +++ b/test-int/mcp/test_list_directory_integration.py @@ -465,3 +465,79 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app): assert "Project Beta Plan.md" in list_text assert "Meeting Minutes" not in list_text assert "matching 'Project*'" in list_text + + +@pytest.mark.asyncio +async def test_list_directory_dot_slash_prefix_paths(mcp_server, app): + """Test directory listing with ./ prefix paths (reproduces bug report issue).""" + + async with Client(mcp_server) as client: + # Create test files in a subdirectory + await client.call_tool( + "write_note", + { + "title": "Artifact One", + "folder": "artifacts", + "content": "# Artifact One\n\nFirst artifact document.", + "tags": "artifact,test", + }, + ) + + await client.call_tool( + "write_note", + { + "title": "Artifact Two", + "folder": "artifacts", + "content": "# Artifact Two\n\nSecond artifact document.", + "tags": "artifact,test", + }, + ) + + # Test normal path without ./ prefix (should work) + normal_result = await client.call_tool( + "list_directory", + { + "dir_name": "artifacts", + "depth": 1, + }, + ) + + assert len(normal_result.content) == 1 + normal_text = normal_result.content[0].text + assert "Artifact One.md" in normal_text + assert "Artifact Two.md" in normal_text + assert "2 files" in normal_text + + # Test with ./ prefix (this was the failing case in the bug report) + dot_slash_result = await client.call_tool( + "list_directory", + { + "dir_name": "./artifacts", + "depth": 1, + }, + ) + + assert len(dot_slash_result.content) == 1 + dot_slash_text = dot_slash_result.content[0].text + + # Should show the same files as normal path + assert "Artifact One.md" in dot_slash_text + assert "Artifact Two.md" in dot_slash_text + assert "2 files" in dot_slash_text + + # Test with trailing slash after ./ prefix + dot_slash_trailing_result = await client.call_tool( + "list_directory", + { + "dir_name": "./artifacts/", + "depth": 1, + }, + ) + + assert len(dot_slash_trailing_result.content) == 1 + dot_slash_trailing_text = dot_slash_trailing_result.content[0].text + + # Should show the same files + assert "Artifact One.md" in dot_slash_trailing_text + assert "Artifact Two.md" in dot_slash_trailing_text + assert "2 files" in dot_slash_trailing_text diff --git a/tests/cli/test_project_commands.py b/tests/cli/test_project_commands.py index 2bc040fe..1411ee6c 100644 --- a/tests/cli/test_project_commands.py +++ b/tests/cli/test_project_commands.py @@ -85,7 +85,12 @@ def test_project_default_command(mock_reload, mock_run, cli_env): # Patching call_put directly since it's imported at the module level # Patch the os.environ for checking - with patch.dict(os.environ, {}, clear=True): + # On Windows, preserve USERPROFILE to allow home directory detection + env_vars = {} + if os.name == 'nt' and 'USERPROFILE' in os.environ: + env_vars['USERPROFILE'] = os.environ['USERPROFILE'] + + with patch.dict(os.environ, env_vars, clear=True): # Patch ConfigManager.set_default_project to prevent validation error with patch("basic_memory.config.ConfigManager.set_default_project"): runner = CliRunner() diff --git a/tests/conftest.py b/tests/conftest.py index f15306c8..5aa979fd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from pathlib import Path from textwrap import dedent from typing import AsyncGenerator +import os import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker @@ -51,6 +52,9 @@ def project_root() -> Path: def config_home(tmp_path, monkeypatch) -> Path: # Patch HOME environment variable for the duration of the test monkeypatch.setenv("HOME", str(tmp_path)) + # On Windows, also set USERPROFILE + if os.name == 'nt': + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Set BASIC_MEMORY_HOME to the test directory monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory")) return tmp_path diff --git a/tests/services/test_directory_service.py b/tests/services/test_directory_service.py index c5d965c1..a40e4c3c 100644 --- a/tests/services/test_directory_service.py +++ b/tests/services/test_directory_service.py @@ -168,6 +168,28 @@ async def test_list_directory_path_normalization(directory_service: DirectorySer assert result_names == base_names +@pytest.mark.asyncio +async def test_list_directory_dot_slash_prefix_normalization( + directory_service: DirectoryService, test_graph +): + """Test that ./ prefixed directory paths are normalized correctly.""" + # This test reproduces the bug report issue where ./dirname fails + base_result = await directory_service.list_directory(dir_name="/test") + + # Test paths with ./ prefix that should be equivalent to /test + dot_paths_to_test = ["./test", "./test/"] + + for path in dot_paths_to_test: + result = await directory_service.list_directory(dir_name=path) + assert len(result) == len(base_result), ( + f"Path '{path}' returned {len(result)} results, expected {len(base_result)}" + ) + # Compare by name since the objects might be different instances + result_names = {node.name for node in result} + base_names = {node.name for node in base_result} + assert result_names == base_names, f"Path '{path}' returned different files than expected" + + @pytest.mark.asyncio async def test_list_directory_glob_no_matches(directory_service: DirectoryService, test_graph): """Test listing directory with glob that matches nothing.""" diff --git a/tests/sync/test_character_conflicts.py b/tests/sync/test_character_conflicts.py index dd7aea34..71c72873 100644 --- a/tests/sync/test_character_conflicts.py +++ b/tests/sync/test_character_conflicts.py @@ -225,7 +225,7 @@ class TestSyncConflictHandling: ): """Test conflict handling when case differences cause issues.""" import platform - + project_dir = project_config.home # Create directory structure that might cause case conflicts @@ -256,7 +256,7 @@ class TestSyncConflictHandling: # Verify entities were created entities = await entity_repository.find_all() - + # On case-insensitive file systems (macOS, Windows), only one entity will be created # On case-sensitive file systems (Linux), two entities will be created if platform.system() in ["Darwin", "Windows"]: @@ -264,7 +264,9 @@ class TestSyncConflictHandling: assert len(entities) >= 1 # Only one of the paths will exist file_paths = [entity.file_path for entity in entities] - assert any(path in ["Finance/investment.md", "finance/investment.md"] for path in file_paths) + assert any( + path in ["Finance/investment.md", "finance/investment.md"] for path in file_paths + ) else: # Case-sensitive file systems (Linux) assert len(entities) >= 2 diff --git a/tests/sync/test_tmp_files.py b/tests/sync/test_tmp_files.py index 2ec7168d..f1797f07 100644 --- a/tests/sync/test_tmp_files.py +++ b/tests/sync/test_tmp_files.py @@ -122,7 +122,7 @@ async def test_rapid_atomic_writes(watch_service, project_config, test_project, await create_test_file(tmp2_path, "Second version") # Simulate the first atomic write - tmp1_path.rename(final_path) + tmp1_path.replace(final_path) # Brief pause to ensure file system registers the change await asyncio.sleep(0.1) @@ -132,7 +132,7 @@ async def test_rapid_atomic_writes(watch_service, project_config, test_project, assert content1 == "First version" # Simulate the second atomic write - tmp2_path.rename(final_path) + tmp2_path.replace(final_path) # Verify content was updated content2 = final_path.read_text(encoding="utf-8") diff --git a/tests/test_config.py b/tests/test_config.py index 4d422e62..d3b79b26 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ """Test configuration management.""" from basic_memory.config import BasicMemoryConfig - +from pathlib import Path class TestBasicMemoryConfig: """Test BasicMemoryConfig behavior with BASIC_MEMORY_HOME environment variable.""" @@ -15,7 +15,7 @@ class TestBasicMemoryConfig: # Should use the default path (home/basic-memory) expected_path = (config_home / "basic-memory").as_posix() - assert config.projects["main"] == expected_path + assert config.projects["main"] == Path(expected_path).as_posix() def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch): """Test that config respects BASIC_MEMORY_HOME environment variable.""" @@ -38,7 +38,7 @@ class TestBasicMemoryConfig: # model_post_init should have added main project with BASIC_MEMORY_HOME assert "main" in config.projects - assert config.projects["main"] == custom_path + assert config.projects["main"] == Path(custom_path).as_posix() def test_model_post_init_fallback_without_basic_memory_home(self, config_home, monkeypatch): """Test that model_post_init falls back to default when BASIC_MEMORY_HOME is not set.""" @@ -52,7 +52,7 @@ class TestBasicMemoryConfig: # model_post_init should have added main project with default path expected_path = (config_home / "basic-memory").as_posix() assert "main" in config.projects - assert config.projects["main"] == expected_path + assert config.projects["main"] == Path(expected_path).as_posix() def test_basic_memory_home_with_relative_path(self, config_home, monkeypatch): """Test that BASIC_MEMORY_HOME works with relative paths.""" diff --git a/tests/test_production_cascade_delete.py b/tests/test_production_cascade_delete.py index b299f036..a50262f2 100644 --- a/tests/test_production_cascade_delete.py +++ b/tests/test_production_cascade_delete.py @@ -9,16 +9,13 @@ Usage: python test_production_cascade_delete.py """ import asyncio -import os -import sqlite3 import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional -import asyncio from sqlalchemy import text -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker class ProductionCascadeTest: @@ -234,7 +231,7 @@ class ProductionCascadeTest: async def restore_backup(self): """Restore database from backup.""" if self.backup_path.exists(): - print(f"🔄 Restoring database from backup...") + print("🔄 Restoring database from backup...") import shutil shutil.copy2(self.backup_path, self.db_path) print("✅ Database restored from backup")