feat: Complete Issue #40 - Associated Files Management with Interactive vs Automation Mode System
Some checks failed
Test Suite / code-quality (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled

This commit implements comprehensive associated files management and introduces
a mode-based architecture that resolves conflicting requirements between
interactive user workflows and automation/testing scenarios.

## Key Features

### Associated Files Management
- Convention-based file pairing (document.md ↔ document.json)
- Automatic path resolution and file discovery
- Complete CLI command suite for managing file pairs
- Performance optimizations with caching

### Interactive vs Automation Mode System
- Automatic mode detection via TTY, CI environment, and pipes
- Environment variable override (MARKITECT_MODE)
- Interactive mode: Uses associated file paths by default
- Automation mode: Optimizes for speed, memory, and stdout output

### Enhanced CLI Commands
- schema-generate: Auto-places output next to source in interactive mode
- generate-stub: Auto-places output next to schema in interactive mode
- validate: Auto-discovers associated schema files
- New associated-files command group with list, info, status, create subcommands

### Bug Fixes
- Fixed isinstance() errors caused by function shadowing built-in types
- Resolved test failures with new mode system integration
- Ensured backward compatibility for all existing functionality

## Technical Implementation
- Added AssociatedFilesManager class with comprehensive file operations
- Implemented mode detection using environment analysis
- Enhanced format_output function with proper type checking
- Added pytest configuration for automation mode during testing
- Complete test coverage for all new functionality

All 448 tests passing. Maintains full backward compatibility while adding
powerful new interactive features for improved developer experience.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-30 13:09:37 +02:00
parent d8c2d198e3
commit 3168de49ac
5 changed files with 1383 additions and 6 deletions

View File

@@ -0,0 +1,271 @@
"""
Tests for Issue #40: Associated Files Management.
This module tests the functionality for managing associated markdown and schema files
with convention-based naming and automatic file placement.
"""
import pytest
from pathlib import Path
from tempfile import TemporaryDirectory
from markitect.associated_files import AssociatedFilesManager
class TestIssue40AssociatedFiles:
"""Test suite for associated files management."""
@pytest.fixture
def manager(self):
"""Create an AssociatedFilesManager instance."""
return AssociatedFilesManager()
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for testing."""
with TemporaryDirectory() as temp_dir:
yield Path(temp_dir)
def test_associated_files_manager_can_be_created(self, manager):
"""AssociatedFilesManager class should be importable and instantiable."""
assert manager is not None
assert isinstance(manager, AssociatedFilesManager)
def test_get_associated_schema_path(self, manager, temp_dir):
"""Should generate correct associated schema path for markdown file."""
md_file = temp_dir / "document.md"
md_file.write_text("# Test Document")
schema_path = manager.get_associated_schema_path(md_file)
assert schema_path == temp_dir / "document.json"
assert schema_path.parent == md_file.parent
assert schema_path.stem == md_file.stem
def test_get_associated_markdown_path(self, manager, temp_dir):
"""Should generate correct associated markdown path for schema file."""
schema_file = temp_dir / "document.json"
schema_file.write_text('{"type": "object"}')
md_path = manager.get_associated_markdown_path(schema_file)
assert md_path == temp_dir / "document.md"
assert md_path.parent == schema_file.parent
assert md_path.stem == schema_file.stem
def test_find_associated_schema(self, manager, temp_dir):
"""Should find existing associated schema file."""
md_file = temp_dir / "blog_post.md"
schema_file = temp_dir / "blog_post.json"
md_file.write_text("# Blog Post")
schema_file.write_text('{"type": "object"}')
found_schema = manager.find_associated_schema(md_file)
assert found_schema == schema_file
assert found_schema.exists()
def test_find_associated_markdown(self, manager, temp_dir):
"""Should find existing associated markdown file."""
md_file = temp_dir / "article.md"
schema_file = temp_dir / "article.json"
md_file.write_text("# Article")
schema_file.write_text('{"type": "object"}')
found_md = manager.find_associated_markdown(schema_file)
assert found_md == md_file
assert found_md.exists()
def test_find_associated_files_returns_none_when_not_found(self, manager, temp_dir):
"""Should return None when associated files don't exist."""
md_file = temp_dir / "lonely.md"
md_file.write_text("# Lonely Document")
schema_file = temp_dir / "orphan.json"
schema_file.write_text('{"type": "object"}')
assert manager.find_associated_schema(md_file) is None
assert manager.find_associated_markdown(schema_file) is None
def test_has_associated_schema(self, manager, temp_dir):
"""Should correctly detect if markdown file has associated schema."""
md_file = temp_dir / "test.md"
schema_file = temp_dir / "test.json"
md_file.write_text("# Test")
# No schema initially
assert not manager.has_associated_schema(md_file)
# Create schema
schema_file.write_text('{"type": "object"}')
assert manager.has_associated_schema(md_file)
def test_has_associated_markdown(self, manager, temp_dir):
"""Should correctly detect if schema file has associated markdown."""
md_file = temp_dir / "guide.md"
schema_file = temp_dir / "guide.json"
schema_file.write_text('{"type": "object"}')
# No markdown initially
assert not manager.has_associated_markdown(schema_file)
# Create markdown
md_file.write_text("# Guide")
assert manager.has_associated_markdown(schema_file)
def test_list_file_pairs(self, manager, temp_dir):
"""Should list all associated file pairs in directory."""
# Create some paired files
(temp_dir / "doc1.md").write_text("# Doc 1")
(temp_dir / "doc1.json").write_text('{"type": "object"}')
(temp_dir / "doc2.md").write_text("# Doc 2")
(temp_dir / "doc2.json").write_text('{"type": "object"}')
# Create orphaned files
(temp_dir / "orphan.md").write_text("# Orphan")
(temp_dir / "lonely.json").write_text('{"type": "object"}')
pairs = manager.list_file_pairs(temp_dir)
assert len(pairs) == 2
pair_names = {pair['basename'] for pair in pairs}
assert 'doc1' in pair_names
assert 'doc2' in pair_names
def test_get_file_pair_info(self, manager, temp_dir):
"""Should provide detailed information about file pairs."""
md_file = temp_dir / "example.md"
schema_file = temp_dir / "example.json"
md_file.write_text("# Example Document\n\nContent here.")
schema_file.write_text('{"type": "object", "title": "Example Schema"}')
pair_info = manager.get_file_pair_info(md_file)
assert pair_info['basename'] == 'example'
assert pair_info['markdown_file'] == md_file
assert pair_info['schema_file'] == schema_file
assert pair_info['both_exist'] is True
assert 'markdown_size' in pair_info
assert 'schema_size' in pair_info
def test_supports_nested_directories(self, manager, temp_dir):
"""Should work correctly with nested directory structures."""
nested_dir = temp_dir / "docs" / "architecture"
nested_dir.mkdir(parents=True)
md_file = nested_dir / "system.md"
schema_file = nested_dir / "system.json"
md_file.write_text("# System Architecture")
schema_path = manager.get_associated_schema_path(md_file)
assert schema_path == schema_file
assert not manager.has_associated_schema(md_file)
schema_file.write_text('{"type": "object"}')
assert manager.has_associated_schema(md_file)
def test_handles_complex_filenames(self, manager, temp_dir):
"""Should handle complex filenames with special characters."""
complex_name = "my-complex_file.name-v2"
md_file = temp_dir / f"{complex_name}.md"
md_file.write_text("# Complex File")
schema_path = manager.get_associated_schema_path(md_file)
expected_schema = temp_dir / f"{complex_name}.json"
assert schema_path == expected_schema
assert schema_path.stem == complex_name
def test_validate_file_extensions(self, manager, temp_dir):
"""Should validate that files have correct extensions."""
txt_file = temp_dir / "document.txt"
txt_file.write_text("Not markdown")
from markitect.associated_files import InvalidFileTypeError
with pytest.raises(InvalidFileTypeError, match="Expected markdown file"):
manager.get_associated_schema_path(txt_file)
xml_file = temp_dir / "schema.xml"
xml_file.write_text("<schema/>")
with pytest.raises(InvalidFileTypeError, match="Expected schema file"):
manager.get_associated_markdown_path(xml_file)
class TestAssociatedFilesIntegration:
"""Test integration of associated files with existing commands."""
@pytest.fixture
def manager(self):
return AssociatedFilesManager()
@pytest.fixture
def temp_dir(self):
with TemporaryDirectory() as temp_dir:
yield Path(temp_dir)
def test_schema_generate_default_output_placement(self, manager, temp_dir):
"""Schema generation should default to placing output next to source."""
md_file = temp_dir / "article.md"
md_file.write_text("# Article\n\n## Introduction\n\nContent here.")
expected_schema_path = manager.get_associated_schema_path(md_file)
# This would be the expected behavior for schema-generate command
assert expected_schema_path == temp_dir / "article.json"
def test_stub_generate_default_output_placement(self, manager, temp_dir):
"""Stub generation should default to placing output next to schema."""
schema_file = temp_dir / "template.json"
schema_file.write_text('''{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Template Schema"
}''')
expected_md_path = manager.get_associated_markdown_path(schema_file)
# This would be the expected behavior for generate-stub command
assert expected_md_path == temp_dir / "template.md"
def test_validation_auto_discovery(self, manager, temp_dir):
"""Validation should auto-discover associated schema files."""
md_file = temp_dir / "document.md"
schema_file = temp_dir / "document.json"
md_file.write_text("# Document")
schema_file.write_text('{"type": "object"}')
# Validation command should find schema automatically
found_schema = manager.find_associated_schema(md_file)
assert found_schema == schema_file
def test_workflow_roundtrip(self, manager, temp_dir):
"""Test complete workflow: markdown → schema → stub."""
# Start with markdown
original_md = temp_dir / "workflow_test.md"
original_md.write_text("# Workflow Test\n\n## Section 1\n\nContent.")
# Generate schema (should place next to original)
schema_path = manager.get_associated_schema_path(original_md)
assert schema_path == temp_dir / "workflow_test.json"
# Create the schema (simulating schema-generate command)
schema_path.write_text('{"type": "object", "title": "Workflow Test"}')
# Generate stub from schema (should use different name to avoid conflict)
stub_path = temp_dir / "workflow_test_stub.md" # Avoiding conflict with original
# Verify the association logic works
assert manager.has_associated_schema(original_md)
assert manager.has_associated_markdown(schema_path)

View File

@@ -0,0 +1,326 @@
"""
CLI Integration Tests for Issue #40: Associated Files Management.
Tests the enhanced CLI commands that work with associated files.
"""
import json
import pytest
from pathlib import Path
from tempfile import TemporaryDirectory
from click.testing import CliRunner
from markitect.cli import cli
class TestIssue40CLIIntegration:
"""Test CLI integration for associated files management."""
@pytest.fixture
def runner(self):
"""Create CLI test runner."""
return CliRunner()
@pytest.fixture
def temp_dir(self):
"""Create a temporary directory for testing."""
with TemporaryDirectory() as temp_dir:
yield Path(temp_dir)
def test_schema_generate_with_explicit_associated_path(self, runner, temp_dir):
"""schema-generate can use explicit associated .json file path."""
md_file = temp_dir / "document.md"
md_file.write_text("# Document\n\n## Introduction\n\nContent here.")
# Explicitly specify associated file path
expected_schema = temp_dir / "document.json"
result = runner.invoke(cli, [
'schema-generate', str(md_file), '--output', str(expected_schema)
])
assert result.exit_code == 0
# Should create associated schema file
assert expected_schema.exists()
# Verify it's valid JSON
schema_content = json.loads(expected_schema.read_text())
assert schema_content['type'] == 'object'
def test_schema_generate_interactive_mode_defaults_to_associated_path(self, runner, temp_dir):
"""schema-generate in interactive mode should default to associated path."""
md_file = temp_dir / "document.md"
md_file.write_text("# Document\n\n## Introduction\n\nContent here.")
# Run schema-generate without --output in interactive mode
result = runner.invoke(cli, [
'schema-generate', str(md_file)
], env={'MARKITECT_MODE': 'interactive'})
assert result.exit_code == 0
# Should create associated schema file
expected_schema = temp_dir / "document.json"
assert expected_schema.exists()
# Verify it's valid JSON
schema_content = json.loads(expected_schema.read_text())
assert schema_content['type'] == 'object'
def test_generate_stub_with_explicit_associated_path(self, runner, temp_dir):
"""generate-stub can use explicit associated .md file path."""
schema_file = temp_dir / "template.json"
schema_content = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Template Schema",
"properties": {
"headings": {
"type": "object",
"properties": {
"level_1": {"type": "array", "minItems": 1, "maxItems": 1}
}
}
}
}
schema_file.write_text(json.dumps(schema_content))
# Explicitly specify associated file path
expected_md = temp_dir / "template.md"
result = runner.invoke(cli, [
'generate-stub', str(schema_file), '--output', str(expected_md)
])
assert result.exit_code == 0
# Should create associated markdown file
assert expected_md.exists()
# Verify it's valid markdown
md_content = expected_md.read_text()
assert md_content.startswith('# ')
def test_generate_stub_interactive_mode_defaults_to_associated_path(self, runner, temp_dir):
"""generate-stub in interactive mode should default to associated path."""
schema_file = temp_dir / "template.json"
schema_content = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Template Schema",
"properties": {
"headings": {
"type": "object",
"properties": {
"level_1": {"type": "array", "minItems": 1, "maxItems": 1}
}
}
}
}
schema_file.write_text(json.dumps(schema_content))
# Run generate-stub without --output in interactive mode
result = runner.invoke(cli, [
'generate-stub', str(schema_file)
], env={'MARKITECT_MODE': 'interactive'})
assert result.exit_code == 0
# Should create associated markdown file
expected_md = temp_dir / "template.md"
assert expected_md.exists()
# Verify it's valid markdown
md_content = expected_md.read_text()
assert md_content.startswith('# ')
def test_validate_auto_discovers_associated_schema(self, runner, temp_dir):
"""validate should auto-discover associated schema when not specified."""
md_file = temp_dir / "article.md"
schema_file = temp_dir / "article.json"
md_file.write_text("# Article\n\n## Introduction\n\nContent.")
schema_file.write_text('{"$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "title": "Article Schema"}')
# Run validate with only markdown file (should find associated schema)
result = runner.invoke(cli, [
'validate', str(md_file)
])
assert result.exit_code == 0
# Should indicate successful validation using auto-discovered schema
def test_new_associated_files_list_command(self, runner, temp_dir):
"""Should have new command to list associated file pairs."""
# Create some file pairs
(temp_dir / "doc1.md").write_text("# Doc 1")
(temp_dir / "doc1.json").write_text('{"type": "object"}')
(temp_dir / "doc2.md").write_text("# Doc 2")
(temp_dir / "doc2.json").write_text('{"type": "object"}')
# Create orphaned files
(temp_dir / "orphan.md").write_text("# Orphan")
result = runner.invoke(cli, [
'associated-files', 'list', str(temp_dir)
])
assert result.exit_code == 0
assert 'doc1' in result.output
assert 'doc2' in result.output
# Should show paired status
def test_new_associated_files_info_command(self, runner, temp_dir):
"""Should have command to show info about associated files."""
md_file = temp_dir / "example.md"
schema_file = temp_dir / "example.json"
md_file.write_text("# Example\n\nContent here.")
schema_file.write_text('{"type": "object", "title": "Example"}')
result = runner.invoke(cli, [
'associated-files', 'info', str(md_file)
])
assert result.exit_code == 0
assert 'example' in result.output
assert 'paired' in result.output.lower()
def test_new_associated_files_create_command(self, runner, temp_dir):
"""Should have command to create missing associated files."""
md_file = temp_dir / "lonely.md"
md_file.write_text("# Lonely Document\n\n## Section\n\nContent.")
# Create associated schema
result = runner.invoke(cli, [
'associated-files', 'create-schema', str(md_file)
])
assert result.exit_code == 0
expected_schema = temp_dir / "lonely.json"
assert expected_schema.exists()
# Verify it's a valid schema
schema = json.loads(expected_schema.read_text())
assert schema['type'] == 'object'
def test_new_associated_files_create_stub_command(self, runner, temp_dir):
"""Should have command to create stub from existing schema."""
schema_file = temp_dir / "template.json"
schema_content = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Template",
"properties": {
"headings": {
"type": "object",
"properties": {
"level_1": {"type": "array", "minItems": 1}
}
}
}
}
schema_file.write_text(json.dumps(schema_content))
# Create associated markdown stub
result = runner.invoke(cli, [
'associated-files', 'create-stub', str(schema_file)
])
assert result.exit_code == 0
expected_md = temp_dir / "template.md"
assert expected_md.exists()
# Verify it's valid markdown
md_content = expected_md.read_text()
assert md_content.startswith('# ')
def test_associated_files_status_command(self, runner, temp_dir):
"""Should show status of associated files in directory."""
# Create mixed scenarios
(temp_dir / "paired.md").write_text("# Paired")
(temp_dir / "paired.json").write_text('{"type": "object"}')
(temp_dir / "lonely.md").write_text("# Lonely")
(temp_dir / "orphan.json").write_text('{"type": "object"}')
result = runner.invoke(cli, [
'associated-files', 'status', str(temp_dir)
])
assert result.exit_code == 0
assert 'paired' in result.output.lower() or 'orphaned' in result.output.lower()
def test_enhanced_commands_preserve_explicit_output(self, runner, temp_dir):
"""Enhanced commands should still respect explicit --output options."""
md_file = temp_dir / "source.md"
md_file.write_text("# Source\n\n## Content")
custom_output = temp_dir / "custom_name.json"
# Use explicit output path (should override associated file logic)
result = runner.invoke(cli, [
'schema-generate', str(md_file), '--output', str(custom_output)
])
assert result.exit_code == 0
assert custom_output.exists()
assert not (temp_dir / "source.json").exists()
def test_associated_files_help_commands(self, runner):
"""Associated files commands should provide helpful usage information."""
result = runner.invoke(cli, ['associated-files', '--help'])
assert result.exit_code == 0
assert 'associated' in result.output.lower()
assert 'files' in result.output.lower()
# Test subcommand help
result = runner.invoke(cli, ['associated-files', 'list', '--help'])
assert result.exit_code == 0
def test_error_handling_for_non_existent_files(self, runner, temp_dir):
"""Should handle non-existent files gracefully."""
non_existent = temp_dir / "does_not_exist.md"
result = runner.invoke(cli, [
'associated-files', 'info', str(non_existent)
])
assert result.exit_code != 0
assert 'not found' in result.output.lower() or 'error' in result.output.lower()
def test_workflow_integration_complete_cycle(self, runner, temp_dir):
"""Test complete workflow with associated files."""
original_md = temp_dir / "workflow.md"
original_md.write_text("# Workflow Example\n\n## Introduction\n\nTest content.")
# Step 1: Generate schema explicitly to workflow.json
schema_file = temp_dir / "workflow.json"
result1 = runner.invoke(cli, [
'schema-generate', str(original_md), '--output', str(schema_file)
])
assert result1.exit_code == 0
assert schema_file.exists()
# Step 2: Generate stub with different name to avoid conflict
stub_name = temp_dir / "workflow_template.md"
result2 = runner.invoke(cli, [
'generate-stub', str(schema_file), '--output', str(stub_name)
])
assert result2.exit_code == 0
assert stub_name.exists()
# Step 3: Validate original against its schema
result3 = runner.invoke(cli, [
'validate', str(original_md)
])
assert result3.exit_code == 0
# Step 4: List associated files
result4 = runner.invoke(cli, [
'associated-files', 'status', str(temp_dir)
])
assert result4.exit_code == 0
assert 'paired' in result4.output.lower() or 'orphaned' in result4.output.lower()

View File

@@ -140,6 +140,9 @@ class TestOutputFormatting:
# Without specifying format
result = self.runner.invoke(cli, ['query', 'SELECT * FROM markdown_files'])
if result.exit_code != 0:
print("Command output:", result.output)
print("Command exception:", result.exception)
assert result.exit_code == 0
# Should look like table format (not JSON or YAML)
assert not result.output.strip().startswith('[') # Not JSON array