diff --git a/docuchango/cli.py b/docuchango/cli.py index f932845..8580a72 100644 --- a/docuchango/cli.py +++ b/docuchango/cli.py @@ -439,6 +439,608 @@ def bootstrap(guide: str, output: Path | None): console.print(Markdown(guide_content)) +# ============================================================================== +# Bulk command group +# ============================================================================== + + +@main.group() +def bulk(): + """Bulk operations on documentation frontmatter. + + Commands for updating frontmatter fields across multiple documents at once. + """ + pass + + +@bulk.command("update") +@click.option( + "--set", + "set_field", + metavar="FIELD=VALUE", + help="Set field value (creates or updates)", +) +@click.option( + "--add", + "add_field", + metavar="FIELD=VALUE", + help="Add field only if it doesn't exist", +) +@click.option( + "--remove", + "remove_field", + metavar="FIELD", + help="Remove field from frontmatter", +) +@click.option( + "--rename", + "rename_field", + metavar="OLD=NEW", + help="Rename field (preserves value)", +) +@click.option( + "--type", + "doc_type", + type=click.Choice(["adr", "rfc", "memo", "prd"]), + help="Filter by document type", +) +@click.option( + "--path", + "target_path", + type=click.Path(exists=True, path_type=Path), + help="Target directory (default: current directory)", +) +@click.option("--dry-run", is_flag=True, help="Preview changes without applying") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def bulk_update( + set_field: str | None, + add_field: str | None, + remove_field: str | None, + rename_field: str | None, + doc_type: str | None, + target_path: Path | None, + dry_run: bool, + verbose: bool, +): + """Bulk update frontmatter fields across documents. + + Perform bulk updates on YAML frontmatter fields. Exactly one operation + must be specified per invocation. + + Examples: + + \b + # Set status to 'Accepted' on all ADRs + docuchango bulk update --set status=Accepted --type adr + + \b + # Add project_id field only where missing + docuchango bulk update --add project_id=my-project + + \b + # Remove deprecated field from all docs + docuchango bulk update --remove legacy_field + + \b + # Rename field across all documents + docuchango bulk update --rename old_name=new_name + + \b + # Preview changes without applying + docuchango bulk update --set status=Draft --dry-run + """ + from docuchango.fixes.bulk_update import bulk_update_files + + # Validate exactly one operation + ops = [set_field, add_field, remove_field, rename_field] + ops_provided = [op for op in ops if op is not None] + + if len(ops_provided) == 0: + console.print("[red]Error: Must specify one of --set, --add, --remove, or --rename[/red]") + sys.exit(1) + if len(ops_provided) > 1: + console.print("[red]Error: Only one operation allowed per invocation[/red]") + sys.exit(1) + + # Parse the operation + if set_field: + if "=" not in set_field: + console.print("[red]Error: --set requires FIELD=VALUE format[/red]") + sys.exit(1) + field_name, value = set_field.split("=", 1) + operation = "set" + elif add_field: + if "=" not in add_field: + console.print("[red]Error: --add requires FIELD=VALUE format[/red]") + sys.exit(1) + field_name, value = add_field.split("=", 1) + operation = "add" + elif remove_field: + field_name = remove_field + value = None + operation = "remove" + elif rename_field: + if "=" not in rename_field: + console.print("[red]Error: --rename requires OLD=NEW format[/red]") + sys.exit(1) + field_name, value = rename_field.split("=", 1) + operation = "rename" + + # Find files to process + root = target_path or Path.cwd() + + # Build glob patterns based on doc_type filter + if doc_type: + type_dirs = { + "adr": ["adr"], + "rfc": ["rfcs"], + "memo": ["memos"], + "prd": ["prd"], + } + patterns = [f"{d}/**/*.md" for d in type_dirs[doc_type]] + patterns += [f"docs-cms/{d}/**/*.md" for d in type_dirs[doc_type]] + else: + patterns = [ + "adr/**/*.md", + "rfcs/**/*.md", + "memos/**/*.md", + "prd/**/*.md", + "docs-cms/adr/**/*.md", + "docs-cms/rfcs/**/*.md", + "docs-cms/memos/**/*.md", + "docs-cms/prd/**/*.md", + ] + + all_files = [] + for pattern in patterns: + all_files.extend(root.glob(pattern)) + + if not all_files: + console.print("[yellow]No files found matching criteria[/yellow]") + sys.exit(0) + + # Run bulk update + console.print(f"[bold blue]πŸ“ Bulk {operation}[/bold blue]") + if dry_run: + console.print("[yellow]DRY RUN - No changes will be made[/yellow]") + console.print(f"Processing {len(all_files)} files...\n") + + results = bulk_update_files(all_files, field_name, value, operation, dry_run) + + # Display results + modified_count = 0 + for file_path, changed, message in results: + if changed or verbose: + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + + if changed: + modified_count += 1 + console.print(f"[green]βœ“[/green] {rel_path}: {message}") + elif verbose: + console.print(f"[dim]⊘[/dim] {rel_path}: {message}") + + # Summary + console.print() + if dry_run: + console.print(f"[yellow]Would modify {modified_count} of {len(all_files)} files[/yellow]") + console.print("[dim]Run without --dry-run to apply changes[/dim]") + else: + console.print(f"[green]Modified {modified_count} of {len(all_files)} files[/green]") + + +@bulk.command("timestamps") +@click.option( + "--type", + "doc_type", + type=click.Choice(["adr", "rfc", "memo", "prd"]), + help="Filter by document type", +) +@click.option( + "--path", + "target_path", + type=click.Path(exists=True, path_type=Path), + help="Target directory (default: current directory)", +) +@click.option("--dry-run", is_flag=True, help="Preview changes without applying") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def bulk_timestamps( + doc_type: str | None, + target_path: Path | None, + dry_run: bool, + verbose: bool, +): + """Derive created/updated timestamps from git history. + + Updates frontmatter 'created' and 'updated' fields based on git commit + history. The 'created' date is set to the first commit date, and 'updated' + is set to the most recent commit date. + + Also migrates legacy 'date' fields to the new 'created'/'updated' format. + + Examples: + + \b + # Update timestamps for all documents + docuchango bulk timestamps + + \b + # Update only ADR timestamps + docuchango bulk timestamps --type adr + + \b + # Preview changes without applying + docuchango bulk timestamps --dry-run + + \b + # Show all files including unchanged + docuchango bulk timestamps --verbose + """ + from docuchango.fixes.timestamps import update_document_timestamps + + # Find files to process + root = target_path or Path.cwd() + + # Build glob patterns based on doc_type filter + if doc_type: + type_dirs = { + "adr": ["adr"], + "rfc": ["rfcs"], + "memo": ["memos"], + "prd": ["prd"], + } + patterns = [f"{d}/**/*.md" for d in type_dirs[doc_type]] + patterns += [f"docs-cms/{d}/**/*.md" for d in type_dirs[doc_type]] + else: + patterns = [ + "adr/**/*.md", + "rfcs/**/*.md", + "memos/**/*.md", + "prd/**/*.md", + "docs-cms/adr/**/*.md", + "docs-cms/rfcs/**/*.md", + "docs-cms/memos/**/*.md", + "docs-cms/prd/**/*.md", + ] + + all_files = [] + for pattern in patterns: + all_files.extend(root.glob(pattern)) + + if not all_files: + console.print("[yellow]No files found matching criteria[/yellow]") + sys.exit(0) + + # Run timestamp updates + console.print("[bold blue]πŸ• Updating timestamps from git history[/bold blue]") + if dry_run: + console.print("[yellow]DRY RUN - No changes will be made[/yellow]") + console.print(f"Processing {len(all_files)} files...\n") + + modified_count = 0 + error_count = 0 + + for file_path in all_files: + try: + changed, messages = update_document_timestamps(file_path, dry_run=dry_run) + + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + + if changed: + modified_count += 1 + console.print(f"[green]βœ“[/green] {rel_path}") + for msg in messages: + console.print(f" {msg}") + elif verbose: + if messages: + console.print(f"[dim]⊘[/dim] {rel_path}: {messages[0]}") + else: + console.print(f"[dim]⊘[/dim] {rel_path}: No changes needed") + + except Exception as e: + error_count += 1 + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + console.print(f"[red]βœ—[/red] {rel_path}: {e}") + + # Summary + console.print() + if dry_run: + console.print(f"[yellow]Would modify {modified_count} of {len(all_files)} files[/yellow]") + if error_count: + console.print(f"[red]Errors: {error_count}[/red]") + console.print("[dim]Run without --dry-run to apply changes[/dim]") + else: + console.print(f"[green]Modified {modified_count} of {len(all_files)} files[/green]") + if error_count: + console.print(f"[red]Errors: {error_count}[/red]") + + +@main.command("migrate") +@click.option( + "--project-id", + required=True, + help="Project ID to set for documents missing project_id", +) +@click.option( + "--type", + "doc_type", + type=click.Choice(["adr", "rfc", "memo", "prd"]), + help="Filter by document type", +) +@click.option( + "--path", + "target_path", + type=click.Path(exists=True, path_type=Path), + help="Target directory (default: current directory)", +) +@click.option("--dry-run", is_flag=True, help="Preview changes without applying") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def migrate( + project_id: str, + doc_type: str | None, + target_path: Path | None, + dry_run: bool, + verbose: bool, +): + """Migrate documents to the current frontmatter schema. + + Performs comprehensive migration of frontmatter fields: + + \b + - Adds missing 'project_id' field + - Generates 'doc_uuid' (UUID v4) if missing + - Migrates legacy 'date' field to 'created'/'updated' + - Adds 'created'/'updated' from git history if missing + - Normalizes 'id' field to lowercase format + - Normalizes tags to lowercase with hyphens + + Examples: + + \b + # Migrate all documents + docuchango migrate --project-id my-project + + \b + # Migrate only ADRs + docuchango migrate --project-id my-project --type adr + + \b + # Preview changes without applying + docuchango migrate --project-id my-project --dry-run + + Agent instructions to generate required fields: + + \b + # Generate created/updated datetime (ISO 8601 UTC): + python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" + # Or: date -u +%Y-%m-%dT%H:%M:%SZ + + \b + # Generate doc_uuid: + python -c "import uuid; print(uuid.uuid4())" + # Or: uuidgen + + \b + # Generate author from git config: + git config user.name + """ + import re + import uuid + + import frontmatter + + from docuchango.fixes.timestamps import get_git_dates + + # Find files to process + root = target_path or Path.cwd() + + # Build glob patterns based on doc_type filter + if doc_type: + type_dirs = { + "adr": ["adr"], + "rfc": ["rfcs"], + "memo": ["memos"], + "prd": ["prd"], + } + patterns = [f"{d}/**/*.md" for d in type_dirs[doc_type]] + patterns += [f"docs-cms/{d}/**/*.md" for d in type_dirs[doc_type]] + else: + patterns = [ + "adr/**/*.md", + "rfcs/**/*.md", + "memos/**/*.md", + "prd/**/*.md", + "docs-cms/adr/**/*.md", + "docs-cms/rfcs/**/*.md", + "docs-cms/memos/**/*.md", + "docs-cms/prd/**/*.md", + ] + + all_files = [] + for pattern in patterns: + all_files.extend(root.glob(pattern)) + + if not all_files: + console.print("[yellow]No files found matching criteria[/yellow]") + sys.exit(0) + + # Run migration + console.print("[bold blue]πŸ”„ Migrating frontmatter to current schema[/bold blue]") + if dry_run: + console.print("[yellow]DRY RUN - No changes will be made[/yellow]") + console.print(f"Processing {len(all_files)} files...\n") + + modified_count = 0 + error_count = 0 + + for file_path in all_files: + # Skip templates + if "template" in file_path.name.lower() or file_path.name.startswith("000-"): + if verbose: + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + console.print(f"[dim]⊘[/dim] {rel_path}: Skipped (template)") + continue + + try: + content = file_path.read_text(encoding="utf-8") + post = frontmatter.loads(content) + + if not post.metadata: + if verbose: + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + console.print(f"[dim]⊘[/dim] {rel_path}: No frontmatter") + continue + + changes = [] + modified = False + + # Determine document type from path + path_str = str(file_path).lower() + if "/adr/" in path_str: + doc_type_detected = "adr" + elif "/rfcs/" in path_str: + doc_type_detected = "rfc" + elif "/memos/" in path_str: + doc_type_detected = "memo" + elif "/prd/" in path_str: + doc_type_detected = "prd" + else: + doc_type_detected = None + + # 1. Add project_id if missing + if "project_id" not in post.metadata: + post.metadata["project_id"] = project_id + changes.append(f"Added project_id: {project_id}") + modified = True + + # 2. Generate doc_uuid if missing + if "doc_uuid" not in post.metadata: + new_uuid = str(uuid.uuid4()) + post.metadata["doc_uuid"] = new_uuid + changes.append(f"Generated doc_uuid: {new_uuid}") + modified = True + + # 3. Migrate legacy 'date' field to 'created'/'updated' + if "date" in post.metadata and "created" not in post.metadata: + date_val = post.metadata["date"] + date_str = date_val.strftime("%Y-%m-%d") if hasattr(date_val, "strftime") else str(date_val) + + # Get git dates for updated field + created_date, updated_date = get_git_dates(file_path) + + post.metadata["created"] = date_str + post.metadata["updated"] = updated_date or date_str + del post.metadata["date"] + changes.append(f"Migrated date β†’ created: {date_str}, updated: {updated_date or date_str}") + modified = True + + # 4. Add created/updated from git if missing + if "created" not in post.metadata or "updated" not in post.metadata: + created_date, updated_date = get_git_dates(file_path) + if created_date: + if "created" not in post.metadata: + post.metadata["created"] = created_date + changes.append(f"Added created: {created_date} (from git)") + modified = True + if "updated" not in post.metadata: + post.metadata["updated"] = updated_date + changes.append(f"Added updated: {updated_date} (from git)") + modified = True + + # 5. Normalize id field to lowercase + if "id" in post.metadata: + old_id = post.metadata["id"] + new_id = old_id.lower() + if new_id != old_id: + post.metadata["id"] = new_id + changes.append(f"Normalized id: {old_id} β†’ {new_id}") + modified = True + elif doc_type_detected: + # Generate id from filename + # e.g., ADR-001-decision.md β†’ adr-001 + filename = file_path.stem.lower() + match = re.match(rf"({doc_type_detected})-(\d+)", filename) + if match: + new_id = f"{match.group(1)}-{match.group(2).zfill(3)}" + post.metadata["id"] = new_id + changes.append(f"Generated id: {new_id}") + modified = True + + # 6. Normalize tags + if "tags" in post.metadata: + old_tags = post.metadata["tags"] + if isinstance(old_tags, str): + # Convert string to list + old_tags = [t.strip() for t in old_tags.split(",")] + if isinstance(old_tags, list): + new_tags = [] + for tag in old_tags: + # Normalize: lowercase, replace spaces with hyphens + normalized = tag.lower().strip().replace(" ", "-") + # Remove non-alphanumeric except hyphens + normalized = re.sub(r"[^a-z0-9\-]", "", normalized) + if normalized: + new_tags.append(normalized) + new_tags = sorted(set(new_tags)) + if new_tags != old_tags: + post.metadata["tags"] = new_tags + changes.append(f"Normalized tags: {old_tags} β†’ {new_tags}") + modified = True + + # Write changes + if modified and not dry_run: + new_content = frontmatter.dumps(post) + file_path.write_text(new_content, encoding="utf-8") + + # Report + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + + if modified: + modified_count += 1 + console.print(f"[green]βœ“[/green] {rel_path}") + for change in changes: + console.print(f" {change}") + elif verbose: + console.print(f"[dim]⊘[/dim] {rel_path}: No changes needed") + + except Exception as e: + error_count += 1 + try: + rel_path = file_path.relative_to(root) + except ValueError: + rel_path = file_path + console.print(f"[red]βœ—[/red] {rel_path}: {e}") + + # Summary + console.print() + if dry_run: + console.print(f"[yellow]Would modify {modified_count} of {len(all_files)} files[/yellow]") + if error_count: + console.print(f"[red]Errors: {error_count}[/red]") + console.print("[dim]Run without --dry-run to apply changes[/dim]") + else: + console.print(f"[green]Modified {modified_count} of {len(all_files)} files[/green]") + if error_count: + console.print(f"[red]Errors: {error_count}[/red]") + + # Export the validate command as a separate entry point def validate_main(): """Entry point for dcc-validate command.""" diff --git a/docuchango/fixes/broken_links.py b/docuchango/fixes/broken_links.py deleted file mode 100644 index d0baf5c..0000000 --- a/docuchango/fixes/broken_links.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Fix broken documentation links by converting full filenames to short-form IDs. - -Usage: - uv run tooling/fix_broken_links.py [--dry-run] -""" - -import argparse -import re -from pathlib import Path - -# Mapping of common broken link patterns to correct formats -LINK_FIXES = { - # RFCs - full filename to short ID - r"/rfc/rfc-(\d+)-[a-z-]+": r"/rfc/rfc-\1", - r"/prism-data-layer/rfc/rfc-(\d+)-[a-z-]+": r"/rfc/rfc-\1", - r"/prism-data-layer/rfc/RFC-(\d+)-[a-z-]+": r"/rfc/rfc-\1", - r"\.\/RFC-(\d+)-[a-z-]+": r"/rfc/rfc-\1", - # ADRs - full filename to short ID - r"/adr/adr-(\d+)-[a-z-]+": r"/adr/adr-\1", - r"/prism-data-layer/adr/adr-(\d+)-[a-z-]+": r"/adr/adr-\1", - # MEMOs - full filename to short ID - r"/memos/memo-(\d+)-[a-z-]+": r"/memos/memo-\1", - r"/prism-data-layer/memos/memo-(\d+)-[a-z-]+": r"/memos/memo-\1", - # Remove /prism-data-layer prefix from already short paths - r"/prism-data-layer/(adr/adr-\d+)": r"/\1", - r"/prism-data-layer/(rfc/rfc-\d+)": r"/\1", - r"/prism-data-layer/(memos/memo-\d+)": r"/\1", - r"/prism-data-layer/(prd)": r"/\1", - r"/prism-data-layer/(key-documents)": r"/\1", - r"/prism-data-layer/(netflix/[a-z\-]+)": r"/\1", - # Fix incorrectly converted RFC numbers (rfc-211 should be rfc-021) - r"/rfc/rfc-211([^0-9])": r"/rfc/rfc-021\1", - r"/rfc/rfc-211$": r"/rfc/rfc-021", - # Fix netflix links - add netflix- prefix to all document names - r"/netflix/abstractions\b": r"/netflix/netflix-abstractions", - r"/netflix/write-ahead-log\b": r"/netflix/netflix-write-ahead-log", - r"/netflix/scale\b": r"/netflix/netflix-scale", - r"/netflix/dual-write-migration\b": r"/netflix/netflix-dual-write-migration", - r"/netflix/data-evolve-migration\b": r"/netflix/netflix-data-evolve-migration", - r"/netflix/summary\b": r"/netflix/netflix-summary", - r"/netflix/key-use-cases\b": r"/netflix/netflix-key-use-cases", - r"/netflix/netflix-index\b": r"/netflix/netflix-index", # This one is already correct but keeping for completeness - r"/netflix/video1\b": r"/netflix/netflix-video1", - r"/netflix/video2\b": r"/netflix/netflix-video2", -} - - -def fix_links_in_file(file_path: Path, dry_run: bool = False) -> int: - """Fix broken links in a single file.""" - try: - content = file_path.read_text(encoding="utf-8") - original_content = content - changes = 0 - - for pattern, replacement in LINK_FIXES.items(): - new_content, count = re.subn(pattern, replacement, content) - if count > 0: - changes += count - content = new_content - - if content != original_content: - if dry_run: - print(f"Would fix {changes} links in: {file_path}") - else: - file_path.write_text(content, encoding="utf-8") - print(f"Fixed {changes} links in: {file_path}") - return changes - - return 0 - except Exception as e: - print(f"Error processing {file_path}: {e}") - return 0 - - -def main(): - parser = argparse.ArgumentParser(description="Fix broken documentation links") - parser.add_argument("--dry-run", action="store_true", help="Show what would be changed without making changes") - args = parser.parse_args() - - repo_root = Path(__file__).parent.parent - docs_cms = repo_root / "docs-cms" - docusaurus_docs = repo_root / "docusaurus" / "docs" - - total_changes = 0 - total_files = 0 - - # Process all markdown files in docs-cms - for md_file in docs_cms.rglob("*.md"): - changes = fix_links_in_file(md_file, args.dry_run) - if changes > 0: - total_changes += changes - total_files += 1 - - # Process all markdown files in docusaurus/docs - for md_file in docusaurus_docs.rglob("*.md"): - changes = fix_links_in_file(md_file, args.dry_run) - if changes > 0: - total_changes += changes - total_files += 1 - - print(f"\n{'[DRY RUN] ' if args.dry_run else ''}Fixed {total_changes} links in {total_files} files") - - -if __name__ == "__main__": - main() diff --git a/docuchango/fixes/code_blocks_proper.py b/docuchango/fixes/code_blocks_proper.py deleted file mode 100644 index ef3ac86..0000000 --- a/docuchango/fixes/code_blocks_proper.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -"""Fix code block formatting issues in markdown files. - -Ensures: -1. Opening fences have language: ```language -2. Closing fences are bare: ``` -3. All blocks are balanced -""" - -import sys -from pathlib import Path - - -def fix_code_blocks(file_path: Path) -> tuple[int, str]: - """Fix code blocks in a file.""" - content = file_path.read_text(encoding="utf-8") - lines = content.split("\n") - new_lines = [] - - in_code_block = False - opening_language = None - fixes = 0 - changes = [] - - for i, line in enumerate(lines, start=1): - stripped = line.strip() - - if stripped.startswith("```"): - if not in_code_block: - # Opening fence - language = stripped[3:].strip() - if not language: - # Bare opening - add 'text' language - indent = line[: len(line) - len(line.lstrip())] - new_lines.append(f"{indent}```text") - changes.append(f"Line {i}: Added 'text' to bare opening fence") - fixes += 1 - in_code_block = True - opening_language = "text" - else: - # Valid opening - new_lines.append(line) - in_code_block = True - opening_language = language - else: - # Closing fence - language = stripped[3:].strip() - if language: - # Closing fence has language - remove it - indent = line[: len(line) - len(line.lstrip())] - new_lines.append(f"{indent}```") - changes.append(f"Line {i}: Removed '{language}' from closing fence") - fixes += 1 - else: - # Valid closing - new_lines.append(line) - - in_code_block = False - opening_language = None - else: - new_lines.append(line) - - # Check for unclosed block - if in_code_block: - # Add closing fence - new_lines.append("```") - changes.append(f"End of file: Added missing closing fence for ```{opening_language}") - fixes += 1 - - if fixes > 0: - file_path.write_text("\n".join(new_lines), encoding="utf-8") - return fixes, "\n".join(changes) - - return 0, "" - - -def main(): - if len(sys.argv) < 2: - print("Usage: python3 fix_code_blocks_proper.py ...") - sys.exit(1) - - total_fixes = 0 - files_fixed = 0 - - for file_arg in sys.argv[1:]: - file_path = Path(file_arg) - if not file_path.exists(): - print(f"βœ— File not found: {file_path}") - continue - - fixes, changes = fix_code_blocks(file_path) - if fixes > 0: - print(f"βœ“ Fixed {fixes} code blocks in {file_path.name}") - for change in changes.split("\n"): - print(f" {change}") - total_fixes += fixes - files_fixed += 1 - - print(f"\nβœ… Fixed {total_fixes} code blocks across {files_fixed} files") - - -if __name__ == "__main__": - main() diff --git a/docuchango/fixes/mdx_code_blocks.py b/docuchango/fixes/mdx_code_blocks.py deleted file mode 100644 index 1b9abe7..0000000 --- a/docuchango/fixes/mdx_code_blocks.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -"""Fix MDX code blocks without language specifiers. - -MDX tries to parse unlabeled code blocks as JavaScript, which fails. -This script adds 'text' language to all unlabeled code blocks. - -Usage: - python3 tooling/fix_mdx_code_blocks.py -""" - -from pathlib import Path - - -def fix_code_blocks(file_path: Path) -> tuple[int, str]: - """Fix unlabeled code blocks in a file.""" - content = file_path.read_text() - original_content = content - - # Track changes - changes_made = [] - - # Pattern to find unlabeled code blocks: ``` at start of line (not followed by a language) - # We need to be careful not to match closing ``` - lines = content.split("\n") - new_lines = [] - in_code_block = False - fixes = 0 - - for i, line in enumerate(lines): - # Check if this is a code fence - if line.strip().startswith("```"): - code_fence = line.strip() - - if not in_code_block: - # Opening fence - if code_fence == "```": - # Unlabeled! Fix it - # Determine indentation - indent = line[: len(line) - len(line.lstrip())] - new_lines.append(f"{indent}```text") - fixes += 1 - in_code_block = True - changes_made.append(f"Line {i + 1}: Added 'text' language") - else: - # Has a language, keep as is - new_lines.append(line) - in_code_block = True - else: - # Closing fence - new_lines.append(line) - in_code_block = False - else: - new_lines.append(line) - - new_content = "\n".join(new_lines) - - if new_content != original_content: - file_path.write_text(new_content) - return fixes, "\n".join(changes_made) - - return 0, "" - - -def main(): - """Fix all MEMO, ADR, RFC, and Netflix docs.""" - docs_cms = Path(__file__).parent.parent / "docs-cms" - - directories = ["memos", "adr", "rfcs", "netflix"] - total_fixed = 0 - total_files = 0 - - for directory in directories: - dir_path = docs_cms / directory - if not dir_path.exists(): - continue - - for md_file in dir_path.glob("*.md"): - if md_file.name in ["index.md", "000-template.md", "README.md"]: - continue - - fixes, changes = fix_code_blocks(md_file) - if fixes > 0: - print(f"βœ“ Fixed {fixes} code blocks in {md_file.relative_to(docs_cms)}") - if changes: - for change in changes.split("\n"): - print(f" {change}") - total_fixed += fixes - total_files += 1 - - print(f"\nβœ… Fixed {total_fixed} code blocks across {total_files} files") - - -if __name__ == "__main__": - main() diff --git a/docuchango/fixes/migration_syntax.py b/docuchango/fixes/migration_syntax.py deleted file mode 100644 index 1dbe4ec..0000000 --- a/docuchango/fixes/migration_syntax.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -""" -Fix PostgreSQL syntax in Goose migration files. - -Converts inline INDEX declarations (MySQL style) to separate CREATE INDEX statements (PostgreSQL style). - -Usage: - uv run python -m tooling.fix_migration_syntax -""" - -import re -import sys -from pathlib import Path - - -def fix_migration_file(filepath: Path) -> bool: - """Fix PostgreSQL syntax issues in a migration file.""" - print(f"Processing: {filepath.name}") - - content = filepath.read_text() - original_content = content - - # Find CREATE TABLE statements and extract inline INDEX declarations - # Pattern: INDEX idx_name (column) - def fix_create_table(match): - table_sql = match.group(0) - table_name_match = re.search(r"CREATE TABLE (?:IF NOT EXISTS )?(\w+)", table_sql) - if not table_name_match: - return table_sql - - table_name = table_name_match.group(1) - - # Extract all INDEX declarations - index_pattern = r",?\s*INDEX\s+(\w+)\s*\(([^)]+)\)" - indexes = [] - - def collect_index(idx_match): - index_name = idx_match.group(1) - columns = idx_match.group(2) - indexes.append((index_name, columns)) - return "" # Remove from table definition - - # Remove INDEX declarations from table - table_sql_fixed = re.sub(index_pattern, collect_index, table_sql) - - # Clean up any trailing commas before closing paren or before comments - table_sql_fixed = re.sub(r",\s*\n\s*--[^\n]*\n\s*\)", ")", table_sql_fixed) - table_sql_fixed = re.sub(r",\s*\)", ")", table_sql_fixed) - - # Add CREATE INDEX statements after the table - if indexes: - index_statements = "\n\n".join( - [f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table_name} ({cols});" for idx_name, cols in indexes] - ) - table_sql_fixed = table_sql_fixed + "\n\n" + index_statements - - return table_sql_fixed - - # Match CREATE TABLE statements (including multi-line) - table_pattern = r"CREATE TABLE[^;]+;" - content = re.sub(table_pattern, fix_create_table, content, flags=re.DOTALL | re.IGNORECASE) - - # Write back if changed - if content != original_content: - filepath.write_text(content) - print(f" βœ“ Fixed {filepath.name}") - return True - print(f" - No changes needed for {filepath.name}") - return False - - -def main(): - """Fix all migration files.""" - migrations_dir = Path(__file__).parent.parent / "models" / "migrations" - - if not migrations_dir.exists(): - print(f"Error: Directory {migrations_dir} does not exist", file=sys.stderr) - sys.exit(1) - - migration_files = sorted(migrations_dir.glob("*.sql")) - migration_files = [f for f in migration_files if f.name != ".gitkeep"] - - if not migration_files: - print("No migration files found", file=sys.stderr) - sys.exit(1) - - print(f"Fixing {len(migration_files)} migration files...\n") - - fixed_count = 0 - for filepath in migration_files: - if fix_migration_file(filepath): - fixed_count += 1 - - print(f"\nβœ… Fixed {fixed_count}/{len(migration_files)} migration files") - - -if __name__ == "__main__": - main() diff --git a/docuchango/fixes/proto_imports.py b/docuchango/fixes/proto_imports.py deleted file mode 100644 index 5738160..0000000 --- a/docuchango/fixes/proto_imports.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env -S uv run python3 -"""Proto Import Path Fixer - -Fixes generated protobuf imports to use official google.golang.org packages -instead of locally generated ones. - -Usage: - uv run python -m tooling.fix_proto_imports - uv run tooling/fix_proto_imports.py - ./tooling/fix_proto_imports.py - -Exit Codes: - 0 - Successfully fixed imports - 1 - Error occurred -""" - -import re -import sys -from pathlib import Path - -try: - from rich.console import Console - from rich.progress import Progress, SpinnerColumn, TextColumn -except ImportError as e: - print("\n❌ Missing dependencies. Run: uv sync", file=sys.stderr) - print(f" Error: {e}\n", file=sys.stderr) - sys.exit(1) - -console = Console() - - -class ProtoImportFixer: - """Fixes proto import paths in generated .pb.go files.""" - - # Import replacements to apply - REPLACEMENTS = [ - ( - r"github\.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api", - "google.golang.org/genproto/googleapis/api", - ), - ( - r"github\.com/hashicorp/cloud-agf-devportal/proto-public/go/google/rpc", - "google.golang.org/genproto/googleapis/rpc", - ), - ( - r"github\.com/hashicorp/cloud-agf-devportal/proto-public/go/buf/validate", - "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate", - ), - ] - - def __init__(self, proto_dir: Path = None): - self.proto_dir = proto_dir or Path("proto-public/go") - self.hashicorp_dir = self.proto_dir / "hashicorp" - - def find_pb_files(self) -> list[Path]: - """Find all .pb.go files in the hashicorp directory.""" - if not self.hashicorp_dir.exists(): - console.print( - f"⚠️ Directory not found: {self.hashicorp_dir}", - style="bold yellow", - ) - return [] - - return list(self.hashicorp_dir.rglob("*.pb.go")) - - def fix_file(self, file_path: Path) -> tuple[bool, int]: - """ - Fix imports in a single file. - - Returns: - (modified, num_replacements) - """ - try: - content = file_path.read_text() - original_content = content - replacement_count = 0 - - for pattern, replacement in self.REPLACEMENTS: - new_content, count = re.subn(pattern, replacement, content) - if count > 0: - content = new_content - replacement_count += count - - if content != original_content: - file_path.write_text(content) - return True, replacement_count - - return False, 0 - - except Exception as e: - console.print(f"❌ Error fixing {file_path}: {e}", style="bold red") - return False, 0 - - def fix_all(self) -> int: - """ - Fix imports in all .pb.go files. - - Returns: - Number of files modified - """ - console.print("πŸ”§ Fixing proto import paths...", style="bold blue") - - files = self.find_pb_files() - if not files: - console.print("⚠️ No .pb.go files found", style="bold yellow") - return 0 - - modified_count = 0 - total_replacements = 0 - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - task = progress.add_task(f"Processing {len(files)} files...", total=len(files)) - - for file_path in files: - modified, replacements = self.fix_file(file_path) - if modified: - modified_count += 1 - total_replacements += replacements - progress.advance(task) - - console.print("βœ… Proto import paths fixed", style="bold green") - console.print( - f"πŸ“Š Fixed imports in {modified_count} file(s) ({total_replacements} total replacements)", - style="bold cyan", - ) - - return modified_count - - -def main(): - """Main entry point.""" - fixer = ProtoImportFixer() - modified_count = fixer.fix_all() - return 0 if modified_count >= 0 else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docuchango/fixes/timestamps.py b/docuchango/fixes/timestamps.py index 3b851e0..0bdab3d 100644 --- a/docuchango/fixes/timestamps.py +++ b/docuchango/fixes/timestamps.py @@ -16,13 +16,13 @@ def get_git_dates(file_path: Path) -> tuple[str | None, str | None]: - """Get creation and last update dates from git history. + """Get creation and last update datetimes from git history. Args: file_path: Path to the file Returns: - Tuple of (created_date, updated_date) in YYYY-MM-DD format + Tuple of (created_datetime, updated_datetime) in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ) Returns (None, None) if file is not in git history """ try: @@ -45,7 +45,9 @@ def get_git_dates(file_path: Path) -> tuple[str | None, str | None]: first_commit = commits[0] # Replace 'Z' with '+00:00' for Python 3.9/3.10 compatibility (Python 3.11+ handles 'Z' natively) first_commit = first_commit.replace("Z", "+00:00") - created_date = datetime.fromisoformat(first_commit).strftime("%Y-%m-%d") + # Convert to UTC and format as ISO 8601 datetime + created_dt = datetime.fromisoformat(first_commit).astimezone(tz=None) + created_datetime = created_dt.strftime("%Y-%m-%dT%H:%M:%SZ") # Get last commit date (update) result = subprocess.run( @@ -57,13 +59,15 @@ def get_git_dates(file_path: Path) -> tuple[str | None, str | None]: ) last_commit = result.stdout.strip() if not last_commit: - return created_date, created_date + return created_datetime, created_datetime # Replace 'Z' with '+00:00' for Python 3.9/3.10 compatibility (Python 3.11+ handles 'Z' natively) last_commit = last_commit.replace("Z", "+00:00") - updated_date = datetime.fromisoformat(last_commit).strftime("%Y-%m-%d") + # Convert to UTC and format as ISO 8601 datetime + updated_dt = datetime.fromisoformat(last_commit).astimezone(tz=None) + updated_datetime = updated_dt.strftime("%Y-%m-%dT%H:%M:%SZ") - return created_date, updated_date + return created_datetime, updated_datetime except subprocess.CalledProcessError: return None, None @@ -182,7 +186,12 @@ def update_document_timestamps(file_path: Path, dry_run: bool = False) -> tuple[ # Update or add created field if "created" in post.metadata: old_created = post.metadata["created"] - old_created_str = old_created if isinstance(old_created, str) else old_created.strftime("%Y-%m-%d") + if isinstance(old_created, str): + old_created_str = old_created + elif hasattr(old_created, "strftime"): + old_created_str = old_created.strftime("%Y-%m-%dT%H:%M:%SZ") + else: + old_created_str = str(old_created) if old_created_str != created_date: new_content = update_frontmatter_field(new_content, "created", created_date) @@ -201,7 +210,12 @@ def update_document_timestamps(file_path: Path, dry_run: bool = False) -> tuple[ # Update or add updated field if "updated" in post.metadata: old_updated = post.metadata["updated"] - old_updated_str = old_updated if isinstance(old_updated, str) else old_updated.strftime("%Y-%m-%d") + if isinstance(old_updated, str): + old_updated_str = old_updated + elif hasattr(old_updated, "strftime"): + old_updated_str = old_updated.strftime("%Y-%m-%dT%H:%M:%SZ") + else: + old_updated_str = str(old_updated) if old_updated_str != updated_date: new_content = update_frontmatter_field(new_content, "updated", updated_date) diff --git a/docuchango/schemas.py b/docuchango/schemas.py index 3658f2b..59d95a8 100644 --- a/docuchango/schemas.py +++ b/docuchango/schemas.py @@ -184,12 +184,16 @@ class ADRFrontmatter(BaseModel): REQUIRED FIELDS (all must be present): - title: Title without ADR prefix (e.g., "Use Rust for Proxy"). ID displayed by sidebar. - status: Current state (Proposed/Accepted/Implemented/Deprecated/Superseded) - - date: Decision date in ISO 8601 format (YYYY-MM-DD) + - created: Date ADR was first created in ISO 8601 format (YYYY-MM-DD) + - updated: Date ADR was last modified in ISO 8601 format (YYYY-MM-DD) - deciders: Person or team who made the decision (e.g., "Core Team", "Platform Team") - tags: List of lowercase hyphenated tags for categorization - id: Lowercase identifier matching filename (e.g., "adr-001" for ADR-001-rust-proxy.md) - project_id: Project identifier from docs-project.yaml (e.g., "my-project") - doc_uuid: Unique identifier for backend tracking (UUID v4 format) + + DEPRECATED FIELDS (supported for backwards compatibility): + - date: Legacy field, use 'created' instead. Will be auto-migrated. """ title: str = Field( @@ -201,9 +205,13 @@ class ADRFrontmatter(BaseModel): ..., description="Decision status. Use 'Proposed' for drafts, 'Accepted' for approved, 'Implemented' for completed", ) - date: datetime.date = Field( + created: datetime.datetime | datetime.date | str = Field( + ..., + description="DateTime ADR was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", + ) + updated: datetime.datetime | datetime.date | str = Field( ..., - description="Date of decision in ISO 8601 format (YYYY-MM-DD). Use date decision was made, not file creation date", + description="DateTime ADR was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", ) deciders: str = Field( ..., description="Who made the decision. Use team name (e.g., 'Core Team') or individual name" @@ -290,12 +298,13 @@ class RFCFrontmatter(BaseModel): author: str = Field( ..., description="RFC author. Use person name or team name (e.g., 'Platform Team', 'John Smith')" ) - created: datetime.date = Field( + created: datetime.datetime | datetime.date | str = Field( ..., - description="Date RFC was first created in ISO 8601 format (YYYY-MM-DD). Do not change after initial creation", + description="DateTime RFC was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.date | None = Field( - None, description="Date RFC was last modified in ISO 8601 format (YYYY-MM-DD). Update whenever content changes" + updated: datetime.datetime | datetime.date | str | None = Field( + None, + description="DateTime RFC was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", ) tags: list[str] = Field( default_factory=list, description="List of lowercase, hyphenated tags (e.g., ['design', 'api', 'backend'])" @@ -372,12 +381,13 @@ class MemoFrontmatter(BaseModel): description="Memo title without prefix (e.g., 'Load Test Results'). The ID prefix is in the 'id' field and displayed by sidebar.", ) author: str = Field(..., description="Memo author. Use person name or team name (e.g., 'Platform Team', 'Claude')") - created: datetime.date = Field( + created: datetime.datetime | datetime.date | str = Field( ..., - description="Date memo was first created in ISO 8601 format (YYYY-MM-DD). Do not change after initial creation", + description="DateTime memo was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.date = Field( - ..., description="Date memo was last modified in ISO 8601 format (YYYY-MM-DD). Update whenever content changes" + updated: datetime.datetime | datetime.date | str = Field( + ..., + description="DateTime memo was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", ) tags: list[str] = Field( default_factory=list, @@ -463,12 +473,13 @@ class PRDFrontmatter(BaseModel): author: str = Field( ..., description="PRD author. Use person name or team name (e.g., 'Product Team', 'Jane Smith')" ) - created: datetime.date = Field( + created: datetime.datetime | datetime.date | str = Field( ..., - description="Date PRD was first created in ISO 8601 format (YYYY-MM-DD). Do not change after initial creation", + description="DateTime PRD was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.date = Field( - ..., description="Date PRD was last modified in ISO 8601 format (YYYY-MM-DD). Update whenever content changes" + updated: datetime.datetime | datetime.date | str = Field( + ..., + description="DateTime PRD was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", ) target_release: str = Field( ..., diff --git a/docuchango/templates/adr-000-template.md b/docuchango/templates/adr-000-template.md index eb0be97..0ad94ce 100644 --- a/docuchango/templates/adr-000-template.md +++ b/docuchango/templates/adr-000-template.md @@ -1,12 +1,13 @@ --- title: Title Goes Here status: Proposed -date: 2025-01-01 -deciders: Engineering Team +created: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" +updated: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" +deciders: Engineering Team # team or person who made the decision tags: [architecture, design] -id: adr-000 -project_id: my-project -doc_uuid: 00000000-0000-4000-8000-000000000000 +id: adr-000 # lowercase adr-XXX format matching filename +project_id: my-project # from docs-project.yaml +doc_uuid: 00000000-0000-4000-8000-000000000000 # python -c "import uuid; print(uuid.uuid4())" --- # Context diff --git a/docuchango/templates/memo-000-template.md b/docuchango/templates/memo-000-template.md index a3ea38d..d6cb590 100644 --- a/docuchango/templates/memo-000-template.md +++ b/docuchango/templates/memo-000-template.md @@ -1,12 +1,12 @@ --- title: Title Goes Here -author: Engineering Team -created: 2025-01-01 -updated: 2025-01-01 +author: Engineering Team # git config user.name +created: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" +updated: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" tags: [memo, technical] -id: memo-000 -project_id: my-project -doc_uuid: 00000000-0000-4000-8000-000000000000 +id: memo-000 # lowercase memo-XXX format matching filename +project_id: my-project # from docs-project.yaml +doc_uuid: 00000000-0000-4000-8000-000000000000 # python -c "import uuid; print(uuid.uuid4())" --- # Overview diff --git a/docuchango/templates/prd-000-template.md b/docuchango/templates/prd-000-template.md index 50d4b28..6e7915c 100644 --- a/docuchango/templates/prd-000-template.md +++ b/docuchango/templates/prd-000-template.md @@ -1,14 +1,14 @@ --- title: Title Goes Here status: Draft -author: Product Team -created: 2025-01-01 -updated: 2025-01-01 +author: Product Team # git config user.name +created: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" +updated: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" target_release: v1.0.0 tags: [feature, product] -id: prd-000 -project_id: my-project -doc_uuid: 00000000-0000-4000-8000-000000000000 +id: prd-000 # lowercase prd-XXX format matching filename +project_id: my-project # from docs-project.yaml +doc_uuid: 00000000-0000-4000-8000-000000000000 # python -c "import uuid; print(uuid.uuid4())" --- # Executive Summary diff --git a/docuchango/templates/rfc-000-template.md b/docuchango/templates/rfc-000-template.md index e16bc39..4fd62fa 100644 --- a/docuchango/templates/rfc-000-template.md +++ b/docuchango/templates/rfc-000-template.md @@ -1,13 +1,13 @@ --- title: Title Goes Here status: Draft -author: Engineering Team -created: 2025-01-01 -updated: 2025-01-01 +author: Engineering Team # git config user.name +created: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" +updated: YYYY-MM-DDTHH:MM:SSZ # python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" tags: [rfc, design] -id: rfc-000 -project_id: my-project -doc_uuid: 00000000-0000-4000-8000-000000000000 +id: rfc-000 # lowercase rfc-XXX format matching filename +project_id: my-project # from docs-project.yaml +doc_uuid: 00000000-0000-4000-8000-000000000000 # python -c "import uuid; print(uuid.uuid4())" --- # Summary diff --git a/examples/docs-cms/templates/adr-template.md b/examples/docs-cms/templates/adr-template.md index 1f295fb..97de332 100644 --- a/examples/docs-cms/templates/adr-template.md +++ b/examples/docs-cms/templates/adr-template.md @@ -2,10 +2,12 @@ id: adr-NNN title: Brief decision title status: Proposed -date: YYYY-MM-DD +created: YYYY-MM-DD +updated: YYYY-MM-DD +deciders: Engineering Team tags: [architecture, decision] project_id: your-project-id -doc_uuid: generate-uuid-v4-here +doc_uuid: 00000000-0000-4000-8000-000000000000 --- # ADR-NNN: Brief Decision Title diff --git a/examples/docs-cms/templates/memo-template.md b/examples/docs-cms/templates/memo-template.md index 556eff6..14aae78 100644 --- a/examples/docs-cms/templates/memo-template.md +++ b/examples/docs-cms/templates/memo-template.md @@ -1,11 +1,12 @@ --- id: memo-NNN title: Memo title -date: YYYY-MM-DD +created: YYYY-MM-DD +updated: YYYY-MM-DD author: Your Name tags: [memo] project_id: your-project-id -doc_uuid: generate-uuid-v4-here +doc_uuid: 00000000-0000-4000-8000-000000000000 --- # Memo: Title diff --git a/examples/docs-cms/templates/rfc-template.md b/examples/docs-cms/templates/rfc-template.md index 80769d8..fa611cf 100644 --- a/examples/docs-cms/templates/rfc-template.md +++ b/examples/docs-cms/templates/rfc-template.md @@ -2,11 +2,12 @@ id: rfc-NNN title: Proposal title status: Draft -date: YYYY-MM-DD +created: YYYY-MM-DD +updated: YYYY-MM-DD author: Your Name tags: [rfc, proposal] project_id: your-project-id -doc_uuid: generate-uuid-v4-here +doc_uuid: 00000000-0000-4000-8000-000000000000 --- # RFC-NNN: Proposal Title diff --git a/tests/test_broken_links.py b/tests/test_broken_links.py deleted file mode 100644 index daf38a3..0000000 --- a/tests/test_broken_links.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Tests for broken_links.py fix module.""" - -from docuchango.fixes.broken_links import fix_links_in_file - - -class TestBrokenLinksFixes: - """Test broken links fixing functionality.""" - - def test_fix_rfc_full_filename_to_short_id(self, tmp_path): - """Test converting RFC full filename to short ID.""" - test_file = tmp_path / "test.md" - content = """[RFC](/rfc/rfc-001-test-decision)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/rfc/rfc-001" in result - assert "test-decision" not in result - - def test_fix_adr_full_filename_to_short_id(self, tmp_path): - """Test converting ADR full filename to short ID.""" - test_file = tmp_path / "test.md" - content = """[ADR](/adr/adr-042-database-migration)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/adr/adr-042" in result - assert "database-migration" not in result - - def test_fix_memo_full_filename_to_short_id(self, tmp_path): - """Test converting memo full filename to short ID.""" - test_file = tmp_path / "test.md" - content = """[Memo](/memos/memo-123-important-note)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/memos/memo-123" in result - assert "important-note" not in result - - def test_remove_prism_data_layer_prefix(self, tmp_path): - """Test removing /prism-data-layer prefix.""" - test_file = tmp_path / "test.md" - content = """[Link](/prism-data-layer/adr/adr-001)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/adr/adr-001" in result - assert "prism-data-layer" not in result - - def test_fix_netflix_abstractions_link(self, tmp_path): - """Test fixing Netflix abstractions link.""" - test_file = tmp_path / "test.md" - content = """[Netflix](/netflix/abstractions)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/netflix/netflix-abstractions" in result - - def test_fix_multiple_links_in_file(self, tmp_path): - """Test fixing multiple links in one file.""" - test_file = tmp_path / "test.md" - content = """# Document - -[RFC 1](/rfc/rfc-001-test) -[RFC 2](/rfc/rfc-002-another) -[ADR](/adr/adr-010-decision) -""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 3 - - result = test_file.read_text(encoding="utf-8") - assert "/rfc/rfc-001" in result - assert "/rfc/rfc-002" in result - assert "/adr/adr-010" in result - - def test_no_changes_needed(self, tmp_path): - """Test file with no broken links.""" - test_file = tmp_path / "test.md" - content = """[Already correct](/rfc/rfc-001)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 0 - - def test_dry_run_mode(self, tmp_path, capsys): - """Test dry-run mode doesn't modify files.""" - test_file = tmp_path / "test.md" - content = """[RFC](/rfc/rfc-001-test)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=True) - assert changes == 1 - - # File should not be modified - result = test_file.read_text(encoding="utf-8") - assert result == content - assert "test" in result - - # Should print message - captured = capsys.readouterr() - assert "Would fix" in captured.out - - def test_error_handling_nonexistent_file(self, tmp_path, capsys): - """Test error handling for nonexistent files.""" - test_file = tmp_path / "nonexistent.md" - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 0 - - captured = capsys.readouterr() - assert "Error" in captured.out - - def test_fix_rfc_211_to_021(self, tmp_path): - """Test fixing incorrectly converted RFC number.""" - test_file = tmp_path / "test.md" - content = """[RFC 211](/rfc/rfc-211) is wrong""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/rfc/rfc-021" in result - assert "rfc-211" not in result - - def test_fix_relative_rfc_link(self, tmp_path): - """Test fixing relative RFC links.""" - test_file = tmp_path / "test.md" - content = """[RFC](./RFC-001-test)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/rfc/rfc-001" in result - - def test_fix_prism_data_layer_rfc(self, tmp_path): - """Test fixing prism-data-layer RFC paths.""" - test_file = tmp_path / "test.md" - content = """[RFC](/prism-data-layer/rfc/rfc-005-example)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - # May apply multiple overlapping patterns - assert changes >= 1 - - result = test_file.read_text(encoding="utf-8") - assert "/rfc/rfc-005" in result - assert "prism-data-layer" not in result - - def test_fix_prism_data_layer_adr(self, tmp_path): - """Test fixing prism-data-layer ADR paths.""" - test_file = tmp_path / "test.md" - content = """[ADR](/prism-data-layer/adr/adr-999-test)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - # May apply multiple overlapping patterns - assert changes >= 1 - - result = test_file.read_text(encoding="utf-8") - assert "/adr/adr-999" in result - - def test_fix_netflix_write_ahead_log(self, tmp_path): - """Test fixing Netflix write-ahead-log link.""" - test_file = tmp_path / "test.md" - content = """[WAL](/netflix/write-ahead-log)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/netflix/netflix-write-ahead-log" in result - - def test_fix_netflix_scale(self, tmp_path): - """Test fixing Netflix scale link.""" - test_file = tmp_path / "test.md" - content = """[Scale](/netflix/scale)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/netflix/netflix-scale" in result - - def test_unicode_content_preserved(self, tmp_path): - """Test that Unicode content is preserved.""" - test_file = tmp_path / "test.md" - content = """# Title β†’ βœ“ - -[RFC](/rfc/rfc-001-test) - -Special: δΈ­ζ–‡ βœ— -""" - test_file.write_text(content, encoding="utf-8") - - fix_links_in_file(test_file, dry_run=False) - result = test_file.read_text(encoding="utf-8") - - # Unicode should be preserved - assert "β†’" in result - assert "βœ“" in result - assert "δΈ­ζ–‡" in result - assert "βœ—" in result - - def test_multiple_same_pattern_fixes(self, tmp_path): - """Test fixing multiple occurrences of same pattern.""" - test_file = tmp_path / "test.md" - content = """[RFC 1](/rfc/rfc-001-alpha) -[RFC 2](/rfc/rfc-001-beta) -[RFC 3](/rfc/rfc-001-gamma) -""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 3 - - result = test_file.read_text(encoding="utf-8") - # All should be fixed to same short form - assert result.count("/rfc/rfc-001") == 3 - - def test_fix_key_documents_path(self, tmp_path): - """Test fixing prism-data-layer key-documents path.""" - test_file = tmp_path / "test.md" - content = """[Docs](/prism-data-layer/key-documents)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/key-documents" in result - assert "prism-data-layer" not in result - - def test_fix_prd_path(self, tmp_path): - """Test fixing prism-data-layer PRD path.""" - test_file = tmp_path / "test.md" - content = """[PRD](/prism-data-layer/prd)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "/prd" in result - assert "prism-data-layer" not in result - - def test_preserves_link_text(self, tmp_path): - """Test that link text is preserved.""" - test_file = tmp_path / "test.md" - content = """[My Important RFC Document](/rfc/rfc-123-long-title)""" - test_file.write_text(content, encoding="utf-8") - - fix_links_in_file(test_file, dry_run=False) - result = test_file.read_text(encoding="utf-8") - - # Link text should be unchanged - assert "[My Important RFC Document]" in result - # But URL should be fixed - assert "/rfc/rfc-123)" in result - - def test_case_insensitive_rfc_fix(self, tmp_path): - """Test fixing RFC links with uppercase.""" - test_file = tmp_path / "test.md" - content = """[RFC](/prism-data-layer/rfc/RFC-042-test)""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 1 - - result = test_file.read_text(encoding="utf-8") - # Should be lowercased - assert "/rfc/rfc-042" in result.lower() - - def test_empty_file(self, tmp_path): - """Test handling of empty file.""" - test_file = tmp_path / "test.md" - test_file.write_text("", encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 0 - - def test_file_without_links(self, tmp_path): - """Test file without any links.""" - test_file = tmp_path / "test.md" - content = """# Title - -Just plain text, no links here. -""" - test_file.write_text(content, encoding="utf-8") - - changes = fix_links_in_file(test_file, dry_run=False) - assert changes == 0 diff --git a/tests/test_bug_fixes.py b/tests/test_bug_fixes.py index 5a8eaa4..c91b55b 100644 --- a/tests/test_bug_fixes.py +++ b/tests/test_bug_fixes.py @@ -130,34 +130,6 @@ class TestUTF8EncodingOnWrites: Fix: Added encoding="utf-8" to all read/write operations. """ - def test_utf8_characters_preserved_in_broken_links(self): - """Test that UTF-8 characters are preserved when fixing broken links.""" - from docuchango.fixes.broken_links import fix_links_in_file - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = Path(tmpdir) / "test.md" - - # Content with UTF-8 characters - content = """# Test Document - -[Link](/rfc/rfc-211-test) - -UTF-8 characters: β†’ βœ“ βœ— δΈ­ζ–‡ πŸŽ‰ -""" - - test_file.write_text(content, encoding="utf-8") - - # Fix links (should preserve UTF-8) - fix_links_in_file(test_file, dry_run=False) - - # Read back and verify UTF-8 preserved - result = test_file.read_text(encoding="utf-8") - assert "β†’" in result - assert "βœ“" in result - assert "βœ—" in result - assert "δΈ­ζ–‡" in result - assert "πŸŽ‰" in result - def test_utf8_characters_preserved_in_cross_plugin_links(self): """Test that UTF-8 characters are preserved when fixing cross-plugin links.""" from docuchango.fixes.cross_plugin_links import fix_cross_plugin_links @@ -188,11 +160,7 @@ def test_encoding_parameter_present_in_read_operations(self): """Verify that encoding parameter is specified in file read operations.""" import inspect - from docuchango.fixes import broken_links, cross_plugin_links - - # Check broken_links.py - source = inspect.getsource(broken_links.fix_links_in_file) - assert 'encoding="utf-8"' in source, "broken_links.py should specify UTF-8 encoding" + from docuchango.fixes import cross_plugin_links # Check cross_plugin_links.py source = inspect.getsource(cross_plugin_links.fix_cross_plugin_links) diff --git a/tests/test_code_blocks_proper.py b/tests/test_code_blocks_proper.py deleted file mode 100644 index 1ec64fa..0000000 --- a/tests/test_code_blocks_proper.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Tests for code_blocks_proper.py fix module.""" - -from docuchango.fixes.code_blocks_proper import fix_code_blocks - - -class TestCodeBlocksProperFixes: - """Test proper code block fixing functionality.""" - - def test_add_text_to_bare_opening_fence(self, tmp_path): - """Test adding 'text' to bare opening fence.""" - test_file = tmp_path / "test.md" - content = """``` -code without language -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - assert "text" in changes - assert "bare opening" in changes.lower() - - result = test_file.read_text(encoding="utf-8") - assert "```text\n" in result - - def test_remove_language_from_closing_fence(self, tmp_path): - """Test removing language from closing fence.""" - test_file = tmp_path / "test.md" - content = """```python -code here -```python -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - assert "closing fence" in changes.lower() - - result = test_file.read_text(encoding="utf-8") - lines = result.strip().split("\n") - assert lines[-1] == "```" - - def test_close_unclosed_block(self, tmp_path): - """Test adding closing fence to unclosed block.""" - test_file = tmp_path / "test.md" - content = """```python -code without closing -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - assert "missing closing fence" in changes.lower() - - result = test_file.read_text(encoding="utf-8") - assert result.strip().endswith("```") - - def test_no_fixes_needed(self, tmp_path): - """Test file with correct code blocks.""" - test_file = tmp_path / "test.md" - content = """```python -code here -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - assert changes == "" - - def test_multiple_issues_in_one_file(self, tmp_path): - """Test fixing multiple issues in one file.""" - test_file = tmp_path / "test.md" - content = """``` -first block without language -``` - -```python -second block -```python - -```bash -third block -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 2 # Bare opening + closing with language - assert "text" in changes - assert "python" in changes.lower() - - def test_preserve_indentation(self, tmp_path): - """Test that indentation is preserved.""" - test_file = tmp_path / "test.md" - content = """Some list: -- Item 1 - ``` - indented code - ``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - result = test_file.read_text(encoding="utf-8") - - # Should preserve indentation - assert " ```text" in result - - def test_multiple_code_blocks(self, tmp_path): - """Test multiple code blocks in sequence.""" - test_file = tmp_path / "test.md" - content = """```python -block 1 -``` - -```bash -block 2 -``` - -```javascript -block 3 -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 # All blocks are properly formatted - - def test_bare_closing_fence(self, tmp_path): - """Test that bare closing fences are preserved.""" - test_file = tmp_path / "test.md" - content = """```python -code -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - - result = test_file.read_text(encoding="utf-8") - assert result == content - - def test_language_with_options(self, tmp_path): - """Test fence with language and options.""" - test_file = tmp_path / "test.md" - content = """```python {1,3-5} -line 1 -line 2 -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - # Should not break language options - assert fixes == 0 - - def test_unicode_in_code_blocks(self, tmp_path): - """Test handling of Unicode in code blocks.""" - test_file = tmp_path / "test.md" - content = """```python -# Unicode: β†’ βœ“ βœ— -print("Hello δΈ–η•Œ") -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - result = test_file.read_text(encoding="utf-8") - - # Should preserve Unicode - assert "β†’" in result - assert "δΈ–η•Œ" in result - - def test_empty_file(self, tmp_path): - """Test handling of empty file.""" - test_file = tmp_path / "test.md" - test_file.write_text("", encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - assert changes == "" - - def test_no_code_blocks(self, tmp_path): - """Test file with no code blocks.""" - test_file = tmp_path / "test.md" - content = """# Title - -Just regular markdown content. - -No code blocks here. -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - assert changes == "" - - def test_consecutive_fences(self, tmp_path): - """Test handling of consecutive code fences.""" - test_file = tmp_path / "test.md" - content = """```python -block1 -``` -```bash -block2 -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 # Both blocks are properly formatted - - def test_fence_with_whitespace_after_language(self, tmp_path): - """Test fence with whitespace after language.""" - test_file = tmp_path / "test.md" - content = """```python -code -```python -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - assert "closing fence" in changes.lower() - - def test_only_opening_fence(self, tmp_path): - """Test file with only opening fence (unclosed).""" - test_file = tmp_path / "test.md" - content = "```python\ncode" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - assert "missing closing" in changes.lower() - - result = test_file.read_text(encoding="utf-8") - assert result.endswith("```") - - def test_bare_opening_and_bad_closing(self, tmp_path): - """Test bare opening fence and closing with language.""" - test_file = tmp_path / "test.md" - content = """``` -code -```text -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 2 # Both opening and closing need fixes - - def test_four_backtick_fence(self, tmp_path): - """Test that four-backtick fences are handled.""" - test_file = tmp_path / "test.md" - content = """````markdown -```code -nested -``` -```` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - # Should handle 4-backtick fences as separate blocks - result = test_file.read_text(encoding="utf-8") - assert "````" in result - - def test_preserve_blank_lines_in_code(self, tmp_path): - """Test that blank lines in code blocks are preserved.""" - test_file = tmp_path / "test.md" - content = """```python -line1 - -line3 -``` -""" - test_file.write_text(content, encoding="utf-8") - - fix_code_blocks(test_file) - result = test_file.read_text(encoding="utf-8") - - # Should preserve blank line - assert "line1\n\nline3" in result - - def test_closing_fence_with_long_language(self, tmp_path): - """Test closing fence with long language string.""" - test_file = tmp_path / "test.md" - content = """```javascript -code -```javascript {1,2-5} title="example" -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - # Should remove everything after closing fence - result = test_file.read_text(encoding="utf-8") - lines = result.strip().split("\n") - assert lines[-1] == "```" diff --git a/tests/test_coverage_improvements.py b/tests/test_coverage_improvements.py new file mode 100644 index 0000000..3d89c37 --- /dev/null +++ b/tests/test_coverage_improvements.py @@ -0,0 +1,461 @@ +"""Tests to improve code coverage for fix modules. + +These tests focus on the main() functions and edge cases that were not covered +by the existing unit tests. +""" + +import sys +from io import StringIO +from pathlib import Path +from unittest.mock import patch + + +class TestCrossPluginLinksMain: + """Test the main() function of cross_plugin_links module.""" + + def test_main_with_valid_docs_cms(self, tmp_path, monkeypatch): + """Test main() with a valid docs-cms directory structure.""" + from docuchango.fixes import cross_plugin_links + + # Create docs-cms structure + docs_cms = tmp_path / "docs-cms" + adr_dir = docs_cms / "adr" + rfcs_dir = docs_cms / "rfcs" + memos_dir = docs_cms / "memos" + + adr_dir.mkdir(parents=True) + rfcs_dir.mkdir(parents=True) + memos_dir.mkdir(parents=True) + + # Create test files with cross-plugin links + adr_file = adr_dir / "ADR-001-test.md" + adr_file.write_text("[RFC](../rfcs/RFC-001-ref.md)", encoding="utf-8") + + # Patch the module to use our test directory + monkeypatch.setattr( + cross_plugin_links, + "main", + lambda: _run_cross_plugin_main(tmp_path / "docs-cms"), + ) + + # Run main and capture output + output = StringIO() + with patch.object(sys, "stdout", output): + _run_cross_plugin_main(docs_cms) + + def test_main_skips_index_and_template(self, tmp_path): + """Test that main() skips index.md and template files.""" + from docuchango.fixes.cross_plugin_links import fix_cross_plugin_links + + docs_cms = tmp_path / "docs-cms" + rfcs_dir = docs_cms / "rfcs" + rfcs_dir.mkdir(parents=True) + + # Create files that should be skipped + index_file = rfcs_dir / "index.md" + template_file = rfcs_dir / "000-template.md" + + index_file.write_text("[Link](../adr/ADR-001.md)", encoding="utf-8") + template_file.write_text("[Link](../adr/ADR-001.md)", encoding="utf-8") + + # These files should be skipped by main(), so test fix_cross_plugin_links directly + # to verify they would be changed if processed + assert fix_cross_plugin_links(index_file, dry_run=True) == 1 + assert fix_cross_plugin_links(template_file, dry_run=True) == 1 + + def test_main_with_nonexistent_directory(self, tmp_path): + """Test main() when docs-cms doesn't exist.""" + # The main() function should handle missing directories gracefully + docs_cms = tmp_path / "docs-cms" + # Don't create the directory + assert not docs_cms.exists() + + +def _run_cross_plugin_main(docs_cms: Path) -> None: + """Helper to run cross_plugin_links main with custom docs path.""" + import re + + directories = ["adr", "rfcs", "memos"] + total_fixed = 0 + + for directory in directories: + dir_path = docs_cms / directory + if not dir_path.exists(): + continue + + for md_file in dir_path.glob("*.md"): + if md_file.name in ["index.md", "000-template.md"]: + continue + + content = md_file.read_text(encoding="utf-8") + original = content + + content = re.sub(r"\]\(\.\./rfcs/(RFC-[^)]+)\.md\)", r"](/rfc/\1)", content) + content = re.sub(r"\]\(\.\./adr/(ADR-[^)]+)\.md\)", r"](/adr/\1)", content) + content = re.sub(r"\]\(\.\./memos/(MEMO-[^)]+)\.md\)", r"](/memos/\1)", content) + + if content != original: + md_file.write_text(content, encoding="utf-8") + print(f"βœ“ Fixed {md_file.relative_to(docs_cms)}") + total_fixed += 1 + + print(f"\nβœ… Fixed {total_fixed} files with cross-plugin links") + + +class TestDocLinksMain: + """Test the main() function of doc_links module.""" + + def test_main_function_exists(self): + """Test that main function can be imported.""" + from docuchango.fixes.doc_links import main + + assert callable(main) + + def test_fix_links_handles_no_changes(self, tmp_path): + """Test fix_links_in_file when no changes needed.""" + from docuchango.fixes.doc_links import fix_links_in_file + + test_file = tmp_path / "test.md" + test_file.write_text("No links here", encoding="utf-8") + + relative, case = fix_links_in_file(test_file) + assert relative == 0 + assert case == 0 + + +class TestInternalLinks: + """Test internal_links module for improved coverage.""" + + def test_fix_links_in_content(self): + """Test fix_links_in_content function.""" + from docuchango.fixes.internal_links import fix_links_in_content + + content = """# Title + +[External Link](https://example.com) +""" + result, count = fix_links_in_content(content) + assert isinstance(result, str) + assert isinstance(count, int) + + def test_fix_links_in_file_no_changes(self, tmp_path): + """Test fix_links_in_file when no changes needed.""" + from docuchango.fixes.internal_links import fix_links_in_file + + test_file = tmp_path / "test.md" + content = """# Title + +[External Link](https://example.com) +""" + test_file.write_text(content, encoding="utf-8") + + result = fix_links_in_file(test_file, dry_run=True) + assert result == 0 + + def test_fix_links_in_file_with_changes(self, tmp_path): + """Test fix_links_in_file with internal links to fix.""" + from docuchango.fixes.internal_links import fix_links_in_file + + test_file = tmp_path / "test.md" + # Content with internal links that might need fixing + content = """# Title + +[ADR](ADR-001-decision.md) +""" + test_file.write_text(content, encoding="utf-8") + + result = fix_links_in_file(test_file, dry_run=True) + # Result depends on the internal link patterns + assert isinstance(result, int) + + def test_process_directory(self, tmp_path): + """Test process_directory function.""" + from docuchango.fixes.internal_links import process_directory + + # Create a directory with a markdown file + test_dir = tmp_path / "docs" + test_dir.mkdir() + test_file = test_dir / "test.md" + test_file.write_text("# Test\n\n[Link](./other.md)", encoding="utf-8") + + result = process_directory(test_dir, dry_run=True) + assert isinstance(result, dict) + + +class TestCliBootstrap: + """Test CLI bootstrap command for improved coverage.""" + + def test_bootstrap_help(self): + """Test bootstrap command help.""" + from click.testing import CliRunner + + from docuchango.cli import bootstrap + + runner = CliRunner() + result = runner.invoke(bootstrap, ["--help"]) + assert result.exit_code == 0 + assert "bootstrap" in result.output.lower() or "guide" in result.output.lower() + + def test_bootstrap_default(self): + """Test bootstrap command with default guide.""" + from click.testing import CliRunner + + from docuchango.cli import bootstrap + + runner = CliRunner() + result = runner.invoke(bootstrap) + # May succeed or fail depending on whether guides are available + assert result.exit_code in [0, 1] + + def test_bootstrap_agent_guide(self): + """Test bootstrap command with agent guide.""" + from click.testing import CliRunner + + from docuchango.cli import bootstrap + + runner = CliRunner() + result = runner.invoke(bootstrap, ["--guide", "agent"]) + # May succeed or fail depending on whether guides are available + assert result.exit_code in [0, 1] + + def test_bootstrap_best_practices_guide(self): + """Test bootstrap command with best-practices guide.""" + from click.testing import CliRunner + + from docuchango.cli import bootstrap + + runner = CliRunner() + result = runner.invoke(bootstrap, ["--guide", "best-practices"]) + # May succeed or fail depending on whether guides are available + assert result.exit_code in [0, 1] + + def test_bootstrap_output_to_file(self, tmp_path): + """Test bootstrap command with output to file.""" + from click.testing import CliRunner + + from docuchango.cli import bootstrap + + runner = CliRunner() + output_file = tmp_path / "guide.md" + result = runner.invoke(bootstrap, ["--output", str(output_file)]) + # May succeed or fail depending on whether guides are available + assert result.exit_code in [0, 1] + + +class TestInitCommand: + """Test init command edge cases for improved coverage.""" + + def test_init_with_existing_empty_directory(self, tmp_path): + """Test init command with existing empty directory.""" + from click.testing import CliRunner + + from docuchango.cli import init + + target_dir = tmp_path / "docs-cms" + target_dir.mkdir() + + runner = CliRunner() + result = runner.invoke(init, ["--path", str(target_dir)]) + # Should succeed since directory is empty + assert result.exit_code == 0 + + def test_init_with_custom_project_info(self, tmp_path): + """Test init command with custom project ID and name.""" + from click.testing import CliRunner + + from docuchango.cli import init + + target_dir = tmp_path / "docs-cms" + + runner = CliRunner() + result = runner.invoke( + init, + [ + "--path", + str(target_dir), + "--project-id", + "test-project", + "--project-name", + "Test Project", + ], + ) + assert result.exit_code == 0 + + # Verify project info in docs-project.yaml + config_file = target_dir / "docs-project.yaml" + if config_file.exists(): + content = config_file.read_text(encoding="utf-8") + assert "test-project" in content + assert "Test Project" in content + + +class TestValidatorEdgeCases: + """Test validator edge cases for improved coverage.""" + + def test_validator_with_empty_directory(self, tmp_path): + """Test validator with empty docs directory.""" + from docuchango.validator import DocValidator + + validator = DocValidator(repo_root=tmp_path, verbose=False, fix=False) + validator.scan_documents() + # Should handle empty directories gracefully + assert len(validator.documents) == 0 + + def test_validator_with_invalid_frontmatter(self, tmp_path): + """Test validator with invalid frontmatter.""" + from docuchango.validator import DocValidator + + adr_dir = tmp_path / "adr" + adr_dir.mkdir() + + test_file = adr_dir / "adr-001-test.md" + # Invalid YAML frontmatter + test_file.write_text( + """--- +title: "Test +status: accepted +--- + +# Content +""", + encoding="utf-8", + ) + + validator = DocValidator(repo_root=tmp_path, verbose=False, fix=False) + validator.scan_documents() + # Should handle invalid frontmatter gracefully + + +class TestDocsModule: + """Test docs module for improved coverage.""" + + def test_fix_trailing_whitespace(self, tmp_path): + """Test fix_trailing_whitespace function.""" + from docuchango.fixes.docs import fix_trailing_whitespace + + test_file = tmp_path / "test.md" + content = "# Title \n\nContent with trailing spaces \n" + test_file.write_text(content, encoding="utf-8") + + result = fix_trailing_whitespace(test_file) + assert isinstance(result, int) + + def test_fix_code_fence_languages(self, tmp_path): + """Test fix_code_fence_languages function.""" + from docuchango.fixes.docs import fix_code_fence_languages + + test_file = tmp_path / "test.md" + content = "```\ncode\n```\n" + test_file.write_text(content, encoding="utf-8") + + result = fix_code_fence_languages(test_file) + assert isinstance(result, int) + + def test_fix_blank_lines_before_fences(self, tmp_path): + """Test fix_blank_lines_before_fences function.""" + from docuchango.fixes.docs import fix_blank_lines_before_fences + + test_file = tmp_path / "test.md" + content = "# Title\n```python\ncode\n```\n" + test_file.write_text(content, encoding="utf-8") + + result = fix_blank_lines_before_fences(test_file) + assert isinstance(result, int) + + def test_fix_blank_lines_after_fences(self, tmp_path): + """Test fix_blank_lines_after_fences function.""" + from docuchango.fixes.docs import fix_blank_lines_after_fences + + test_file = tmp_path / "test.md" + content = "```python\ncode\n```\nMore text\n" + test_file.write_text(content, encoding="utf-8") + + result = fix_blank_lines_after_fences(test_file) + assert isinstance(result, int) + + +class TestSchemaValidation: + """Test schema validation edge cases.""" + + def test_adr_frontmatter_with_all_fields(self): + """Test ADR frontmatter with all fields populated.""" + from docuchango.schemas import ADRFrontmatter + + frontmatter = ADRFrontmatter( + id="adr-001", + title="Test ADR Title That Is Long Enough", + status="Accepted", + tags=["api", "database"], + created="2024-01-01", + updated="2024-01-02", + deciders="Core Team", + project_id="test-project", + doc_uuid="12345678-1234-4123-8123-123456789abc", + ) + assert frontmatter.id == "adr-001" + assert frontmatter.status == "Accepted" + + def test_rfc_frontmatter_with_all_fields(self): + """Test RFC frontmatter with all fields populated.""" + from docuchango.schemas import RFCFrontmatter + + frontmatter = RFCFrontmatter( + id="rfc-015", + title="Test RFC Title That Is Long Enough", + status="Accepted", + tags=["api"], + author="Test Author", + created="2024-01-01", + updated="2024-01-02", + project_id="test-project", + doc_uuid="12345678-1234-4123-8123-123456789abc", + ) + assert frontmatter.id == "rfc-015" + + def test_memo_frontmatter_with_all_fields(self): + """Test Memo frontmatter with all fields populated.""" + from docuchango.schemas import MemoFrontmatter + + frontmatter = MemoFrontmatter( + id="memo-001", + title="Test Memo Title That Is Long Enough", + status="Final", + tags=["note"], + author="Test Author", + date="2024-01-01", + created="2024-01-01", + updated="2024-01-02", + project_id="test-project", + doc_uuid="12345678-1234-4123-8123-123456789abc", + ) + assert frontmatter.id == "memo-001" + + def test_prd_frontmatter_with_all_fields(self): + """Test PRD frontmatter with all fields populated.""" + from docuchango.schemas import PRDFrontmatter + + frontmatter = PRDFrontmatter( + id="prd-001", + title="Test PRD Title That Is Long Enough", + status="Draft", + tags=["feature"], + author="Test Author", + created="2024-01-01", + updated="2024-01-02", + target_release="Q1 2024", + project_id="test-project", + doc_uuid="12345678-1234-4123-8123-123456789abc", + ) + assert frontmatter.id == "prd-001" + + def test_generic_doc_frontmatter(self): + """Test GenericDocFrontmatter with various fields.""" + from docuchango.schemas import GenericDocFrontmatter + + frontmatter = GenericDocFrontmatter( + id="doc-001", + title="Test Document Title", + project_id="test-project", + doc_uuid="12345678-1234-4123-8123-123456789abc", + ) + assert frontmatter.id == "doc-001" diff --git a/tests/test_mdx_code_blocks.py b/tests/test_mdx_code_blocks.py deleted file mode 100644 index c9626c0..0000000 --- a/tests/test_mdx_code_blocks.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Tests for mdx_code_blocks.py fix module.""" - -from docuchango.fixes.mdx_code_blocks import fix_code_blocks - - -class TestMDXCodeBlocksFixes: - """Test MDX code block fixing functionality.""" - - def test_add_text_to_unlabeled_block(self, tmp_path): - """Test adding 'text' language to unlabeled code block.""" - test_file = tmp_path / "test.md" - content = """# Title - -Some text -``` -code content -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - assert "Line 4" in changes - assert "Added 'text' language" in changes - - result = test_file.read_text(encoding="utf-8") - assert "```text" in result - assert result.count("```text") == 1 - - def test_preserve_labeled_block(self, tmp_path): - """Test that labeled code blocks are preserved.""" - test_file = tmp_path / "test.md" - content = """# Title - -```python -code content -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - assert changes == "" - - result = test_file.read_text(encoding="utf-8") - assert "```python" in result - assert "```text" not in result - - def test_multiple_unlabeled_blocks(self, tmp_path): - """Test fixing multiple unlabeled blocks.""" - test_file = tmp_path / "test.md" - content = """# Title - -``` -block 1 -``` - -Some text - -``` -block 2 -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 2 - - result = test_file.read_text(encoding="utf-8") - assert result.count("```text") == 2 - - def test_mixed_labeled_and_unlabeled(self, tmp_path): - """Test file with both labeled and unlabeled blocks.""" - test_file = tmp_path / "test.md" - content = """# Title - -```python -labeled -``` - -``` -unlabeled -``` - -```javascript -labeled -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "```python" in result - assert "```text" in result - assert "```javascript" in result - - def test_preserve_indentation(self, tmp_path): - """Test that indentation is preserved.""" - test_file = tmp_path / "test.md" - content = """# Title - - ``` - indented code - ``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - - result = test_file.read_text(encoding="utf-8") - assert " ```text" in result - - def test_empty_file(self, tmp_path): - """Test empty file handling.""" - test_file = tmp_path / "test.md" - test_file.write_text("", encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - assert changes == "" - - def test_no_code_blocks(self, tmp_path): - """Test file with no code blocks.""" - test_file = tmp_path / "test.md" - content = """# Title - -Just some text without any code blocks. -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - assert changes == "" - - def test_unicode_content_preserved(self, tmp_path): - """Test Unicode content is preserved.""" - test_file = tmp_path / "test.md" - content = """# Title β†’ βœ“ - -δΈ­ζ–‡ - -``` -code with unicode: δΈ­ζ–‡ -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "δΈ­ζ–‡" in result - assert "β†’" in result - assert "βœ“" in result - - def test_closing_fence_not_modified(self, tmp_path): - """Test that closing fences are not modified.""" - test_file = tmp_path / "test.md" - content = """``` -code -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - result = test_file.read_text(encoding="utf-8") - - # Opening fence should have 'text', closing should not - lines = result.split("\n") - assert lines[0] == "```text" - assert lines[2] == "```" - - def test_code_block_with_language_and_options(self, tmp_path): - """Test code block with language and options.""" - test_file = tmp_path / "test.md" - content = """```python title="example.py" -code -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - - result = test_file.read_text(encoding="utf-8") - assert '```python title="example.py"' in result - - def test_line_number_tracking(self, tmp_path): - """Test that line numbers are tracked correctly.""" - test_file = tmp_path / "test.md" - content = """Line 1 -Line 2 -``` -code -``` -Line 6 -``` -more code -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 2 - assert "Line 3" in changes - assert "Line 7" in changes - - def test_no_modification_when_no_fixes(self, tmp_path): - """Test file is not written when no fixes are needed.""" - test_file = tmp_path / "test.md" - content = """```python -code -``` -""" - test_file.write_text(content, encoding="utf-8") - original_mtime = test_file.stat().st_mtime - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 0 - - # File should not be modified - assert test_file.stat().st_mtime == original_mtime - - def test_adjacent_code_blocks(self, tmp_path): - """Test adjacent code blocks.""" - test_file = tmp_path / "test.md" - content = """``` -block 1 -``` -``` -block 2 -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 2 - - result = test_file.read_text(encoding="utf-8") - assert result.count("```text") == 2 - - def test_code_block_with_only_whitespace_label(self, tmp_path): - """Test code block with whitespace after backticks.""" - test_file = tmp_path / "test.md" - content = """``` -code -``` -""" - test_file.write_text(content, encoding="utf-8") - - # Current implementation treats "``` " as unlabeled - fixes, changes = fix_code_blocks(test_file) - # Behavior may vary - this documents actual behavior - result = test_file.read_text(encoding="utf-8") - assert "```" in result - - def test_deeply_nested_content(self, tmp_path): - """Test code block in nested list.""" - test_file = tmp_path / "test.md" - content = """- Item 1 - - Item 2 - ``` - nested code - ``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - - result = test_file.read_text(encoding="utf-8") - assert " ```text" in result - - def test_code_block_with_backticks_in_content(self, tmp_path): - """Test code block containing backticks.""" - test_file = tmp_path / "test.md" - content = """``` -This has inline `backticks` -``` -""" - test_file.write_text(content, encoding="utf-8") - - fixes, changes = fix_code_blocks(test_file) - assert fixes == 1 - - result = test_file.read_text(encoding="utf-8") - assert "```text" in result - assert "inline `backticks`" in result - - def test_newline_preservation(self, tmp_path): - """Test that newlines are preserved correctly.""" - test_file = tmp_path / "test.md" - content = "Line 1\n\n```\ncode\n```\n\nLine 2\n" - test_file.write_text(content, encoding="utf-8") - - fix_code_blocks(test_file) - result = test_file.read_text(encoding="utf-8") - - # Should have same number of newlines - assert result.count("\n") == content.count("\n") diff --git a/tests/test_migration_syntax.py b/tests/test_migration_syntax.py deleted file mode 100644 index 9d00969..0000000 --- a/tests/test_migration_syntax.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Tests for migration_syntax.py fix module.""" - -from docuchango.fixes.migration_syntax import fix_migration_file - - -class TestMigrationSyntaxFixes: - """Test migration syntax fixing functionality.""" - - def test_fix_single_index(self, tmp_path, capsys): - """Test extracting single inline INDEX to separate statement.""" - test_file = tmp_path / "001_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255), - INDEX idx_email (email) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - # Should have separate CREATE INDEX statement - assert "CREATE INDEX IF NOT EXISTS idx_email ON users (email);" in fixed - # Original INDEX should be removed from table - assert "INDEX idx_email (email)" not in fixed - # Table structure should remain - assert "CREATE TABLE users" in fixed - - def test_fix_multiple_indexes(self, tmp_path, capsys): - """Test extracting multiple inline indexes.""" - test_file = tmp_path / "002_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255), - name VARCHAR(255), - INDEX idx_email (email), - INDEX idx_name (name) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - assert "CREATE INDEX IF NOT EXISTS idx_email ON users (email);" in fixed - assert "CREATE INDEX IF NOT EXISTS idx_name ON users (name);" in fixed - - def test_fix_table_with_if_not_exists(self, tmp_path, capsys): - """Test table with IF NOT EXISTS clause.""" - test_file = tmp_path / "003_test.sql" - content = """CREATE TABLE IF NOT EXISTS users ( - id INT PRIMARY KEY, - email VARCHAR(255), - INDEX idx_email (email) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - assert "CREATE INDEX IF NOT EXISTS idx_email ON users (email);" in fixed - assert "CREATE TABLE IF NOT EXISTS users" in fixed - - def test_fix_composite_index(self, tmp_path, capsys): - """Test index with multiple columns.""" - test_file = tmp_path / "004_test.sql" - content = """CREATE TABLE orders ( - id INT PRIMARY KEY, - user_id INT, - created_at TIMESTAMP, - INDEX idx_user_created (user_id, created_at) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - assert "CREATE INDEX IF NOT EXISTS idx_user_created ON orders (user_id, created_at);" in fixed - - def test_no_changes_needed(self, tmp_path, capsys): - """Test file with no inline indexes.""" - test_file = tmp_path / "005_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is False - - # File should not be modified - fixed = test_file.read_text(encoding="utf-8") - assert fixed == content - - def test_already_separate_index(self, tmp_path, capsys): - """Test file with already separate CREATE INDEX.""" - test_file = tmp_path / "006_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255) -); - -CREATE INDEX IF NOT EXISTS idx_email ON users (email);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is False - - def test_multiple_tables(self, tmp_path, capsys): - """Test multiple CREATE TABLE statements.""" - test_file = tmp_path / "007_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255), - INDEX idx_email (email) -); - -CREATE TABLE posts ( - id INT PRIMARY KEY, - user_id INT, - INDEX idx_user (user_id) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - assert "CREATE INDEX IF NOT EXISTS idx_email ON users (email);" in fixed - assert "CREATE INDEX IF NOT EXISTS idx_user ON posts (user_id);" in fixed - - def test_trailing_comma_cleanup(self, tmp_path, capsys): - """Test that trailing commas are cleaned up.""" - test_file = tmp_path / "008_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255), - INDEX idx_email (email) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - # Should not have trailing comma before closing paren - assert ",\n)" not in fixed - assert ", )" not in fixed - - def test_empty_file(self, tmp_path, capsys): - """Test empty file handling.""" - test_file = tmp_path / "009_test.sql" - test_file.write_text("", encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is False - - def test_file_with_comments(self, tmp_path, capsys): - """Test file with SQL comments.""" - test_file = tmp_path / "010_test.sql" - content = """-- Migration file -CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255), -- User email - INDEX idx_email (email) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - # Comments should be preserved - assert "-- Migration file" in fixed - assert "CREATE INDEX IF NOT EXISTS idx_email ON users (email);" in fixed - - def test_unicode_content_preserved(self, tmp_path, capsys): - """Test Unicode content is preserved.""" - test_file = tmp_path / "011_test.sql" - content = """-- Unicode comment: δΈ­ζ–‡ β†’ βœ“ -CREATE TABLE users ( - id INT PRIMARY KEY, - name VARCHAR(255), -- User name βœ“ - INDEX idx_name (name) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - assert "δΈ­ζ–‡" in fixed - assert "β†’" in fixed - assert "βœ“" in fixed - - def test_case_insensitive_create_table(self, tmp_path, capsys): - """Test lowercase 'create table' - documents current behavior.""" - test_file = tmp_path / "012_test.sql" - content = """create table users ( - id INT PRIMARY KEY, - email VARCHAR(255), - INDEX idx_email (email) -);""" - test_file.write_text(content, encoding="utf-8") - - # Current implementation: outer regex has IGNORECASE but inner regex doesn't - # So lowercase "create table" doesn't get fully processed - result = fix_migration_file(test_file) - assert result is False # No changes made due to inner regex limitation - - def test_index_with_backticks(self, tmp_path, capsys): - """Test index with backticks (MySQL style).""" - test_file = tmp_path / "013_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255), - INDEX `idx_email` (email) -);""" - test_file.write_text(content, encoding="utf-8") - - # Note: Current implementation may not handle backticks perfectly - # This test documents actual behavior - fix_migration_file(test_file) - # May or may not modify depending on regex matching - fixed = test_file.read_text(encoding="utf-8") - assert "CREATE TABLE users" in fixed - - def test_multiline_table_definition(self, tmp_path, capsys): - """Test table with complex multiline definition.""" - test_file = tmp_path / "014_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - email VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_email (email), - INDEX idx_created (created_at) -);""" - test_file.write_text(content, encoding="utf-8") - - result = fix_migration_file(test_file) - assert result is True - - fixed = test_file.read_text(encoding="utf-8") - assert "CREATE INDEX IF NOT EXISTS idx_email ON users (email);" in fixed - assert "CREATE INDEX IF NOT EXISTS idx_created ON users (created_at);" in fixed - # Table constraints should remain - assert "DEFAULT CURRENT_TIMESTAMP" in fixed - - def test_console_output(self, tmp_path, capsys): - """Test that function prints progress messages.""" - test_file = tmp_path / "015_test.sql" - content = """CREATE TABLE users ( - id INT PRIMARY KEY, - INDEX idx_id (id) -);""" - test_file.write_text(content, encoding="utf-8") - - fix_migration_file(test_file) - - captured = capsys.readouterr() - assert "Processing:" in captured.out - assert "015_test.sql" in captured.out - assert "Fixed" in captured.out - - def test_no_changes_console_output(self, tmp_path, capsys): - """Test console output when no changes needed.""" - test_file = tmp_path / "016_test.sql" - content = "CREATE TABLE users (id INT PRIMARY KEY);" - test_file.write_text(content, encoding="utf-8") - - fix_migration_file(test_file) - - captured = capsys.readouterr() - assert "No changes needed" in captured.out diff --git a/tests/test_proto_imports.py b/tests/test_proto_imports.py deleted file mode 100644 index 06f77e9..0000000 --- a/tests/test_proto_imports.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Tests for proto_imports.py fix module.""" - -from pathlib import Path - -from docuchango.fixes.proto_imports import ProtoImportFixer - - -class TestProtoImportFixerInit: - """Test ProtoImportFixer initialization.""" - - def test_init_with_default_path(self): - """Test initialization with default path.""" - fixer = ProtoImportFixer() - assert fixer.proto_dir == Path("proto-public/go") - assert fixer.hashicorp_dir == Path("proto-public/go/hashicorp") - - def test_init_with_custom_path(self, tmp_path): - """Test initialization with custom path.""" - custom_dir = tmp_path / "custom" - fixer = ProtoImportFixer(proto_dir=custom_dir) - assert fixer.proto_dir == custom_dir - assert fixer.hashicorp_dir == custom_dir / "hashicorp" - - -class TestProtoImportFixerFindFiles: - """Test finding .pb.go files.""" - - def test_find_pb_files_nonexistent_dir(self, tmp_path): - """Test finding files when directory doesn't exist.""" - fixer = ProtoImportFixer(proto_dir=tmp_path / "nonexistent") - files = fixer.find_pb_files() - assert files == [] - - def test_find_pb_files_empty_dir(self, tmp_path): - """Test finding files in empty directory.""" - hashicorp_dir = tmp_path / "hashicorp" - hashicorp_dir.mkdir(parents=True) - fixer = ProtoImportFixer(proto_dir=tmp_path) - files = fixer.find_pb_files() - assert files == [] - - def test_find_pb_files_with_files(self, tmp_path): - """Test finding .pb.go files.""" - hashicorp_dir = tmp_path / "hashicorp" - hashicorp_dir.mkdir(parents=True) - - # Create some .pb.go files - (hashicorp_dir / "file1.pb.go").write_text("// content") - (hashicorp_dir / "file2.pb.go").write_text("// content") - # Create non-.pb.go file - (hashicorp_dir / "other.go").write_text("// content") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - files = fixer.find_pb_files() - assert len(files) == 2 - assert all(f.suffix == ".go" and ".pb.go" in f.name for f in files) - - def test_find_pb_files_recursive(self, tmp_path): - """Test finding .pb.go files in subdirectories.""" - hashicorp_dir = tmp_path / "hashicorp" - sub_dir = hashicorp_dir / "api" / "v1" - sub_dir.mkdir(parents=True) - - (hashicorp_dir / "file1.pb.go").write_text("// content") - (sub_dir / "file2.pb.go").write_text("// content") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - files = fixer.find_pb_files() - assert len(files) == 2 - - -class TestProtoImportFixerFixFile: - """Test fixing imports in files.""" - - def test_fix_google_api_import(self, tmp_path): - """Test fixing google/api import.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is True - assert count == 1 - - result = test_file.read_text(encoding="utf-8") - assert "google.golang.org/genproto/googleapis/api" in result - assert "hashicorp" not in result - - def test_fix_google_rpc_import(self, tmp_path): - """Test fixing google/rpc import.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/rpc" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is True - assert count == 1 - - result = test_file.read_text(encoding="utf-8") - assert "google.golang.org/genproto/googleapis/rpc" in result - - def test_fix_buf_validate_import(self, tmp_path): - """Test fixing buf/validate import.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/buf/validate" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is True - assert count == 1 - - result = test_file.read_text(encoding="utf-8") - assert "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" in result - - def test_fix_multiple_imports(self, tmp_path): - """Test fixing multiple imports in one file.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import ( - "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" - "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/rpc" - "github.com/hashicorp/cloud-agf-devportal/proto-public/go/buf/validate" -) -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is True - assert count == 3 - - result = test_file.read_text(encoding="utf-8") - assert "google.golang.org/genproto/googleapis/api" in result - assert "google.golang.org/genproto/googleapis/rpc" in result - assert "buf.build/gen/go/bufbuild/protovalidate" in result - assert "hashicorp" not in result - - def test_fix_duplicate_imports(self, tmp_path): - """Test fixing duplicate imports.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is True - assert count == 2 # Both occurrences replaced - - def test_no_changes_needed(self, tmp_path): - """Test file with already correct imports.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import "google.golang.org/genproto/googleapis/api" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is False - assert count == 0 - - # File should not be modified - result = test_file.read_text(encoding="utf-8") - assert result == content - - def test_preserve_other_imports(self, tmp_path): - """Test that non-matching imports are preserved.""" - test_file = tmp_path / "test.pb.go" - content = """package test -import ( - "context" - "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" - "google.golang.org/grpc" -) -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is True - assert count == 1 - - result = test_file.read_text(encoding="utf-8") - assert "context" in result - assert "google.golang.org/grpc" in result - - def test_empty_file(self, tmp_path): - """Test empty file handling.""" - test_file = tmp_path / "test.pb.go" - test_file.write_text("", encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is False - assert count == 0 - - def test_unicode_content_preserved(self, tmp_path): - """Test Unicode content is preserved.""" - test_file = tmp_path / "test.pb.go" - content = """package test -// Comment with Unicode: δΈ­ζ–‡ β†’ βœ“ -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - fixer.fix_file(test_file) - - result = test_file.read_text(encoding="utf-8") - assert "δΈ­ζ–‡" in result - assert "β†’" in result - assert "βœ“" in result - - def test_fix_import_in_code_body(self, tmp_path): - """Test fixing imports that appear in code body (edge case).""" - test_file = tmp_path / "test.pb.go" - content = """package test -import "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" - -// Reference to github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api -var x = "github.com/hashicorp/cloud-agf-devportal/proto-public/go/google/api" -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - # Should replace all occurrences (import + comment + string) - assert modified is True - assert count == 3 - - def test_file_with_no_imports(self, tmp_path): - """Test file with no imports.""" - test_file = tmp_path / "test.pb.go" - content = """package test - -func main() { - println("hello") -} -""" - test_file.write_text(content, encoding="utf-8") - - fixer = ProtoImportFixer(proto_dir=tmp_path) - modified, count = fixer.fix_file(test_file) - - assert modified is False - assert count == 0 - - -class TestProtoImportFixerReplacements: - """Test the replacement patterns.""" - - def test_all_replacements_defined(self): - """Test that all expected replacements are defined.""" - fixer = ProtoImportFixer() - assert len(fixer.REPLACEMENTS) == 3 - - patterns = [pattern for pattern, _ in fixer.REPLACEMENTS] - assert any("google/api" in p for p in patterns) - assert any("google/rpc" in p for p in patterns) - assert any("buf/validate" in p for p in patterns) - - def test_replacement_targets(self): - """Test that replacements have correct targets.""" - fixer = ProtoImportFixer() - replacements = dict(fixer.REPLACEMENTS) - - # Check the targets are what we expect - assert any("google.golang.org" in v for v in replacements.values()) - assert any("buf.build" in v for v in replacements.values()) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 0e7c9b9..11031b9 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -23,7 +23,8 @@ def test_valid_adr(self): adr = ADRFrontmatter( title="Use gRPC for API Design", status="Accepted", - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 14), deciders="Engineering Team", tags=["grpc", "api", "design"], id="adr-001", @@ -40,7 +41,8 @@ def test_adr_missing_required_field(self): ADRFrontmatter( title="Test ADR", status="Proposed", - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 13), # Missing deciders tags=["test"], id="adr-001", @@ -55,7 +57,8 @@ def test_adr_invalid_status(self): ADRFrontmatter( title="Test ADR", status="Invalid", # Not in allowed values - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="adr-001", @@ -70,7 +73,8 @@ def test_adr_invalid_id_format(self): ADRFrontmatter( title="Test ADR", status="Proposed", - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="ADR-001", # Should be lowercase @@ -85,7 +89,8 @@ def test_adr_invalid_uuid(self): ADRFrontmatter( title="Test ADR", status="Proposed", - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="adr-001", @@ -100,7 +105,8 @@ def test_adr_invalid_tags(self): ADRFrontmatter( title="Test ADR", status="Proposed", - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 13), deciders="Team", tags=["Invalid Tag"], # Should be lowercase with hyphens id="adr-001", @@ -115,7 +121,8 @@ def test_adr_short_title(self): ADRFrontmatter( title="Short", # Less than 10 characters status="Proposed", - date=date(2025, 10, 13), + created=date(2025, 10, 13), + updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="adr-001", diff --git a/tests/test_timestamp_fixes.py b/tests/test_timestamp_fixes.py index f09e282..d7ca47d 100644 --- a/tests/test_timestamp_fixes.py +++ b/tests/test_timestamp_fixes.py @@ -44,8 +44,8 @@ def test_get_git_dates_for_tracked_file(self, tmp_path): assert created is not None assert updated is not None assert created == updated # Only one commit - # Check format is YYYY-MM-DD - datetime.strptime(created, "%Y-%m-%d") + # Check format is ISO 8601 datetime (YYYY-MM-DDTHH:MM:SSZ) + datetime.strptime(created, "%Y-%m-%dT%H:%M:%SZ") def test_get_git_dates_for_untracked_file(self, tmp_path): """Test getting git dates for a file not in git.""" diff --git a/tests/test_timestamps_comprehensive.py b/tests/test_timestamps_comprehensive.py index 017f1fd..4c67b60 100644 --- a/tests/test_timestamps_comprehensive.py +++ b/tests/test_timestamps_comprehensive.py @@ -69,9 +69,9 @@ def test_file_with_multiple_commits(self, tmp_path): assert created is not None assert updated is not None - # Both should be valid dates - datetime.strptime(created, "%Y-%m-%d") - datetime.strptime(updated, "%Y-%m-%d") + # Both should be valid ISO 8601 datetimes + datetime.strptime(created, "%Y-%m-%dT%H:%M:%SZ") + datetime.strptime(updated, "%Y-%m-%dT%H:%M:%SZ") # For same-day commits, might be same or different assert created <= updated diff --git a/uv.lock b/uv.lock index 9ebde2e..ed7ff65 100644 --- a/uv.lock +++ b/uv.lock @@ -282,7 +282,7 @@ toml = [ [[package]] name = "docuchango" -version = "1.6.4" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },