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

@@ -23,8 +23,51 @@ import yaml
from pathlib import Path
from typing import Optional
from tabulate import tabulate
import builtins
from .database import DatabaseManager
def detect_execution_mode():
"""
Detect whether we're running in interactive or automation mode.
Returns:
str: 'interactive' or 'automation'
Detection logic:
- Environment variable MARKITECT_MODE overrides detection
- Interactive: TTY present, not in CI, not in pipe
- Automation: CI environment, pipe/redirect, or explicit setting
"""
# Explicit mode override
mode = os.environ.get('MARKITECT_MODE', '').lower()
if mode in ['interactive', 'automation']:
return mode
# Detect CI environments
ci_indicators = [
'CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS',
'GITLAB_CI', 'JENKINS_URL', 'BUILD_NUMBER'
]
if any(os.environ.get(var) for var in ci_indicators):
return 'automation'
# Check if output is being piped or redirected
if not sys.stdout.isatty():
return 'automation'
# Check if input is being piped
if not sys.stdin.isatty():
return 'automation'
# Default to interactive for terminal usage
return 'interactive'
def should_use_associated_files():
"""Determine if commands should use associated files behavior."""
return detect_execution_mode() == 'interactive'
from .document_manager import DocumentManager
from .serializer import ASTSerializer
from .cache_service import CacheDirectoryService
@@ -80,11 +123,19 @@ def format_output(data, output_format):
return json.dumps(data, indent=2, default=str)
elif output_format == 'yaml':
return yaml.dump(data, default_flow_style=False, allow_unicode=True)
elif output_format == 'simple':
# Simple format - just basic text output
if isinstance(data, builtins.list):
return '\n'.join(str(item) for item in data)
elif isinstance(data, builtins.dict):
return '\n'.join(f"{key}: {value}" for key, value in data.items())
else:
return str(data)
elif output_format == 'table':
try:
# Check if it's a list type
if isinstance(data, (type([]), type(()))):
if data and isinstance(data[0], dict):
if isinstance(data, (builtins.list, builtins.tuple)):
if data and isinstance(data[0], builtins.dict):
# List of dictionaries - format as table
headers = sorted(data[0].keys())
rows = []
@@ -97,7 +148,7 @@ def format_output(data, output_format):
else:
# List of simple values
return tabulate([[item] for item in data], headers=['Value'], tablefmt='grid')
elif isinstance(data, dict):
elif isinstance(data, builtins.dict):
# Single dictionary - format as key-value table
rows = [[key, value] for key, value in data.items()]
return tabulate(rows, headers=['Key', 'Value'], tablefmt='grid')
@@ -1022,8 +1073,10 @@ def generate_schema(config, file_path, max_depth, output, output_format):
markitect schema-generate document.md --output schema.json
"""
try:
# Initialize schema generator
# Initialize schema generator and associated files manager
generator = SchemaGenerator()
from .associated_files import AssociatedFilesManager
associated_files = AssociatedFilesManager()
# Generate schema
schema = generator.generate_schema_from_file(file_path, max_depth=max_depth)
@@ -1036,6 +1089,15 @@ def generate_schema(config, file_path, max_depth, output, output_format):
else:
formatted_output = json.dumps(schema, indent=2, ensure_ascii=False)
# Mode-based output logic
if not output and should_use_associated_files():
# Interactive mode: use associated file path
from .associated_files import AssociatedFilesManager
associated_files = AssociatedFilesManager()
output = associated_files.get_associated_schema_path(file_path)
if config.get('verbose'):
click.echo(f"Interactive mode: using associated file path: {output}", err=True)
# Write to output
if output:
output.write_text(formatted_output, encoding='utf-8')
@@ -1100,12 +1162,25 @@ def validate(config, file_path, schema, schema_json, quiet, detailed_errors, err
"""
try:
validator = SchemaValidator()
from .associated_files import AssociatedFilesManager
associated_files = AssociatedFilesManager()
# Validate exactly one schema source is provided
# Validate schema source or auto-discover
schema_sources = [schema, schema_json]
provided_sources = [s for s in schema_sources if s is not None]
if len(provided_sources) != 1:
if len(provided_sources) == 0:
# Auto-discover associated schema file
auto_schema = associated_files.find_associated_schema(file_path)
if auto_schema:
schema = auto_schema
if config.get('verbose'):
click.echo(f"Auto-discovered associated schema: {schema}", err=True)
else:
click.echo("Error: No schema specified and no associated schema file found", err=True)
click.echo("Provide --schema FILE or --schema-json JSON, or ensure an associated .json file exists", err=True)
sys.exit(1)
elif len(provided_sources) > 1:
click.echo("Error: Specify exactly one schema source (--schema or --schema-json)", err=True)
sys.exit(1)
@@ -1446,8 +1521,10 @@ def generate_stub(config, schema_file, output, style, title):
click.echo(f"Generating stub from schema: {schema_file}", err=True)
from .stub_generator import StubGenerator
from .associated_files import AssociatedFilesManager
generator = StubGenerator()
associated_files = AssociatedFilesManager()
# Load schema and generate stub content
import json
@@ -1458,6 +1535,13 @@ def generate_stub(config, schema_file, output, style, title):
schema, placeholder_style=style, title=title
)
# Mode-based output logic
if not output and should_use_associated_files():
# Interactive mode: use associated file path
output = associated_files.get_associated_markdown_path(schema_file)
if config.get('verbose'):
click.echo(f"Interactive mode: using associated file path: {output}", err=True)
# Output to file or stdout
if output:
generator.generate_stub_to_file(schema, output, style, title)
@@ -1485,6 +1569,344 @@ def generate_stub(config, schema_file, output, style, title):
sys.exit(1)
@cli.group('associated-files')
@pass_config
def associated_files_group(config):
"""
Manage associated markdown and schema file pairs.
Commands for working with files that follow the convention of having
identical basenames with different extensions (e.g., document.md ↔ document.json).
"""
pass
@associated_files_group.command('list')
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']),
default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']),
help='Output format')
@click.argument('directory', type=click.Path(exists=True, file_okay=False, path_type=Path), default='.')
@pass_config
def list_associated_files(config, format, directory):
"""
List all associated file pairs in a directory.
Shows markdown/schema file pairs that follow the naming convention.
Examples:
markitect associated-files list
markitect associated-files list docs/
markitect associated-files list --format json
"""
try:
from .associated_files import AssociatedFilesManager
manager = AssociatedFilesManager()
if config.get('verbose'):
click.echo(f"Scanning directory: {directory}", err=True)
pairs = manager.list_file_pairs(directory)
if not pairs:
click.echo("No associated file pairs found.")
return
# Format output
if format == 'table':
click.echo(f"Associated File Pairs in {directory}:")
click.echo("=" * 60)
for pair in pairs:
click.echo(f"📄 {pair['basename']}")
click.echo(f" Markdown: {pair['markdown_file'].name}")
click.echo(f" Schema: {pair['schema_file'].name}")
click.echo()
elif format == 'json':
import json
output_data = []
for pair in pairs:
output_data.append({
'basename': pair['basename'],
'markdown_file': str(pair['markdown_file']),
'schema_file': str(pair['schema_file']),
'both_exist': pair['both_exist']
})
click.echo(json.dumps(output_data, indent=2))
elif format == 'yaml':
import yaml
output_data = []
for pair in pairs:
output_data.append({
'basename': pair['basename'],
'markdown_file': str(pair['markdown_file']),
'schema_file': str(pair['schema_file']),
'both_exist': pair['both_exist']
})
click.echo(yaml.dump(output_data, default_flow_style=False))
else:
# Simple format
for pair in pairs:
click.echo(f"{pair['basename']} ({pair['markdown_file'].name}{pair['schema_file'].name})")
if config.get('verbose'):
click.echo(f"Found {len(pairs)} associated file pairs", err=True)
except Exception as e:
click.echo(f"Error listing associated files: {e}", err=True)
if config.get('verbose'):
import traceback
click.echo(traceback.format_exc(), err=True)
sys.exit(1)
@associated_files_group.command('info')
@click.argument('file_path', type=click.Path(exists=True, path_type=Path))
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']),
default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']),
help='Output format')
@pass_config
def associated_files_info(config, file_path, format):
"""
Show detailed information about associated files.
Displays information about a file and its associated counterpart.
Examples:
markitect associated-files info document.md
markitect associated-files info schema.json --format json
"""
try:
from .associated_files import AssociatedFilesManager
manager = AssociatedFilesManager()
info = manager.get_file_pair_info(file_path)
if format == 'table':
click.echo(f"Associated Files Information:")
click.echo("=" * 40)
click.echo(f"Basename: {info['basename']}")
click.echo(f"Markdown: {info['markdown_file']}")
click.echo(f" Exists: {'' if info['markdown_file'].exists() else ''}")
if 'markdown_size' in info:
click.echo(f" Size: {info['markdown_size']} bytes")
click.echo(f"Schema: {info['schema_file']}")
click.echo(f" Exists: {'' if info['schema_file'].exists() else ''}")
if 'schema_size' in info:
click.echo(f" Size: {info['schema_size']} bytes")
click.echo(f"Both exist: {'' if info['both_exist'] else ''}")
elif format == 'json':
import json
# Convert Path objects to strings for JSON serialization
json_info = {k: str(v) if isinstance(v, Path) else v for k, v in info.items()}
click.echo(json.dumps(json_info, indent=2))
elif format == 'yaml':
import yaml
# Convert Path objects to strings for YAML serialization
yaml_info = {k: str(v) if isinstance(v, Path) else v for k, v in info.items()}
click.echo(yaml.dump(yaml_info, default_flow_style=False))
else:
# Simple format
status = "paired" if info['both_exist'] else "orphaned"
click.echo(f"{info['basename']}: {status}")
except Exception as e:
click.echo(f"Error getting file info: {e}", err=True)
sys.exit(1)
@associated_files_group.command('status')
@click.argument('directory', type=click.Path(exists=True, file_okay=False, path_type=Path), default='.')
@click.option('--format', '-f', type=click.Choice(['table', 'json', 'yaml', 'simple']),
default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']),
help='Output format')
@pass_config
def associated_files_status(config, directory, format):
"""
Show status of associated files in a directory.
Displays paired files and orphaned files (files without their counterpart).
Examples:
markitect associated-files status
markitect associated-files status docs/
"""
try:
from .associated_files import AssociatedFilesManager
manager = AssociatedFilesManager()
status = manager.get_directory_status(directory)
if format == 'table':
click.echo(f"Associated Files Status for {directory}:")
click.echo("=" * 50)
click.echo(f"📌 Paired files: {status['paired_files']}")
click.echo(f"📄 Orphaned markdown: {status['orphaned_markdown']}")
click.echo(f"🔧 Orphaned schemas: {status['orphaned_schemas']}")
if status['pairs']:
click.echo("\n📌 Paired Files:")
for pair in status['pairs']:
click.echo(f"{pair['basename']}")
if status['orphaned']['orphaned_markdown']:
click.echo("\n📄 Orphaned Markdown Files:")
for md in status['orphaned']['orphaned_markdown']:
click.echo(f"{md.name}")
if status['orphaned']['orphaned_schemas']:
click.echo("\n🔧 Orphaned Schema Files:")
for schema in status['orphaned']['orphaned_schemas']:
click.echo(f"{schema.name}")
elif format == 'json':
import json
# Convert Path objects to strings for JSON serialization
json_status = {
'directory': str(status['directory']),
'paired_files': status['paired_files'],
'orphaned_markdown': status['orphaned_markdown'],
'orphaned_schemas': status['orphaned_schemas'],
'pairs': [{'basename': p['basename'],
'markdown_file': str(p['markdown_file']),
'schema_file': str(p['schema_file'])}
for p in status['pairs']],
'orphaned': {
'orphaned_markdown': [str(f) for f in status['orphaned']['orphaned_markdown']],
'orphaned_schemas': [str(f) for f in status['orphaned']['orphaned_schemas']]
}
}
click.echo(json.dumps(json_status, indent=2))
elif format == 'yaml':
import yaml
yaml_status = {
'directory': str(status['directory']),
'paired_files': status['paired_files'],
'orphaned_markdown': status['orphaned_markdown'],
'orphaned_schemas': status['orphaned_schemas'],
'pairs': [{'basename': p['basename'],
'markdown_file': str(p['markdown_file']),
'schema_file': str(p['schema_file'])}
for p in status['pairs']],
'orphaned': {
'orphaned_markdown': [str(f) for f in status['orphaned']['orphaned_markdown']],
'orphaned_schemas': [str(f) for f in status['orphaned']['orphaned_schemas']]
}
}
click.echo(yaml.dump(yaml_status, default_flow_style=False))
else:
# Simple format
click.echo(f"Paired: {status['paired_files']}, Orphaned: {status['orphaned_markdown'] + status['orphaned_schemas']}")
except Exception as e:
click.echo(f"Error getting status: {e}", err=True)
sys.exit(1)
@associated_files_group.command('create-schema')
@click.argument('markdown_file', type=click.Path(exists=True, path_type=Path))
@click.option('--max-depth', '-d', type=int, help='Maximum heading depth to include in schema')
@pass_config
def create_associated_schema(config, markdown_file, max_depth):
"""
Create an associated schema file for a markdown file.
Generates a JSON schema and places it next to the source markdown file
with the same basename but .json extension.
Examples:
markitect associated-files create-schema document.md
markitect associated-files create-schema doc.md --max-depth 3
"""
try:
from .associated_files import AssociatedFilesManager
from .schema_generator import SchemaGenerator
manager = AssociatedFilesManager()
generator = SchemaGenerator()
# Check if associated schema already exists
existing_schema = manager.find_associated_schema(markdown_file)
if existing_schema:
if not click.confirm(f"Associated schema {existing_schema} already exists. Overwrite?"):
click.echo("Operation cancelled.")
return
# Generate schema
schema = generator.generate_schema_from_file(markdown_file, max_depth=max_depth)
# Save to associated path
schema_path = manager.get_associated_schema_path(markdown_file)
import json
with open(schema_path, 'w', encoding='utf-8') as f:
json.dump(schema, f, indent=2, ensure_ascii=False)
click.echo(f"✅ Created associated schema: {schema_path}")
if config.get('verbose'):
properties = schema.get('properties', {})
click.echo(f"Generated schema with {len(properties)} property types", err=True)
except Exception as e:
click.echo(f"Error creating schema: {e}", err=True)
sys.exit(1)
@associated_files_group.command('create-stub')
@click.argument('schema_file', type=click.Path(exists=True, path_type=Path))
@click.option('--style', type=click.Choice(['default', 'custom', 'detailed']),
default='default', help='Placeholder content style')
@click.option('--title', type=str, help='Custom document title')
@pass_config
def create_associated_stub(config, schema_file, style, title):
"""
Create an associated markdown stub for a schema file.
Generates a markdown template and places it next to the source schema file
with the same basename but .md extension.
Examples:
markitect associated-files create-stub schema.json
markitect associated-files create-stub schema.json --style detailed
"""
try:
from .associated_files import AssociatedFilesManager
from .stub_generator import StubGenerator
manager = AssociatedFilesManager()
generator = StubGenerator()
# Check if associated markdown already exists
existing_md = manager.find_associated_markdown(schema_file)
if existing_md:
if not click.confirm(f"Associated markdown {existing_md} already exists. Overwrite?"):
click.echo("Operation cancelled.")
return
# Load schema and generate stub
import json
with open(schema_file, 'r') as f:
schema = json.load(f)
# Save to associated path
md_path = manager.get_associated_markdown_path(schema_file)
generator.generate_stub_to_file(schema, md_path, style, title)
click.echo(f"✅ Created associated stub: {md_path}")
if config.get('verbose'):
content = md_path.read_text()
click.echo(f"Generated {len(content)} characters of content", err=True)
except Exception as e:
click.echo(f"Error creating stub: {e}", err=True)
sys.exit(1)
def main():
"""
Main entry point for the CLI.