markdown-table-fixer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,703 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """Command-line interface for markdown-table-fixer."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from rich.console import Console
15
+ from rich.logging import RichHandler
16
+ import typer
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+ from ._version import __version__
22
+ from .exceptions import (
23
+ FileAccessError,
24
+ TableParseError,
25
+ )
26
+ from .github_client import GitHubClient
27
+ from .models import (
28
+ BlockedPR,
29
+ FileResult,
30
+ OutputFormat,
31
+ PRInfo,
32
+ ScanResult,
33
+ TableViolation,
34
+ )
35
+ from .pr_fixer import PRFixer
36
+ from .pr_scanner import PRScanner
37
+ from .progress_tracker import ProgressTracker
38
+ from .table_fixer import FileFixer
39
+ from .table_parser import MarkdownFileScanner, TableParser
40
+ from .table_validator import TableValidator
41
+
42
+ console = Console()
43
+
44
+
45
+ def version_callback(value: bool) -> None:
46
+ """Show version and exit."""
47
+ if value:
48
+ console.print(f"🏷️ markdown-table-fixer version {__version__}")
49
+ console.print()
50
+ raise typer.Exit()
51
+
52
+
53
+ def setup_logging(
54
+ log_level: str = "INFO", quiet: bool = False, verbose: bool = False
55
+ ) -> None:
56
+ """Configure logging with Rich handler."""
57
+ if quiet:
58
+ log_level = "ERROR"
59
+ elif verbose:
60
+ log_level = "DEBUG"
61
+
62
+ logging.basicConfig(
63
+ level=log_level,
64
+ format="%(message)s",
65
+ handlers=[
66
+ RichHandler(console=console, show_time=False, show_path=False)
67
+ ],
68
+ )
69
+
70
+ # Silence httpx INFO logs to prevent Rich display interruption
71
+ logging.getLogger("httpx").setLevel(logging.WARNING)
72
+
73
+
74
+ # Create Typer app with version in help
75
+ app = typer.Typer(
76
+ name="markdown-table-fixer",
77
+ help=f"markdown-table-fixer version {__version__}\n\nFix markdown table formatting issues",
78
+ add_completion=False,
79
+ rich_markup_mode="rich",
80
+ )
81
+
82
+
83
+ # Update command docstrings with version at module load time
84
+ def _add_version_to_docstring(
85
+ cmd_func: Callable[..., None],
86
+ ) -> Callable[..., None]:
87
+ """Add version info to command docstring."""
88
+ if cmd_func.__doc__:
89
+ cmd_func.__doc__ = (
90
+ f"markdown-table-fixer version {__version__}\n\n" + cmd_func.__doc__
91
+ )
92
+ return cmd_func
93
+
94
+
95
+ @app.callback() # type: ignore[misc]
96
+ def main(
97
+ version: bool = typer.Option(
98
+ False,
99
+ "--version",
100
+ callback=version_callback,
101
+ is_eager=True,
102
+ help="Show version and exit",
103
+ ),
104
+ ) -> None:
105
+ """Markdown table formatter and linter with GitHub integration."""
106
+ pass
107
+
108
+
109
+ @app.command() # type: ignore[misc]
110
+ @_add_version_to_docstring # type: ignore[misc]
111
+ def lint(
112
+ path: Path = typer.Argument(
113
+ Path("."),
114
+ help="Path to scan for markdown files",
115
+ exists=True,
116
+ ),
117
+ output_format: OutputFormat = typer.Option(
118
+ OutputFormat.TEXT,
119
+ "--format",
120
+ help="Output format: text, json",
121
+ case_sensitive=False,
122
+ ),
123
+ quiet: bool = typer.Option(
124
+ False,
125
+ "--quiet",
126
+ "-q",
127
+ help="Suppress output except errors",
128
+ ),
129
+ check: bool = typer.Option(
130
+ False,
131
+ "--check",
132
+ help="Exit with error if issues found (CI mode)",
133
+ ),
134
+ _version: bool = typer.Option(
135
+ False,
136
+ "--version",
137
+ help="Show version and exit",
138
+ callback=version_callback,
139
+ is_eager=True,
140
+ ),
141
+ max_line_length: int = typer.Option(
142
+ 80,
143
+ "--max-line-length",
144
+ help="Maximum line length before adding MD013 disable comments",
145
+ ),
146
+ auto_fix: bool = typer.Option(
147
+ False,
148
+ "--auto-fix/--no-auto-fix",
149
+ help="Automatically fix issues found (default: disabled)",
150
+ ),
151
+ fail_on_error: bool = typer.Option(
152
+ True,
153
+ "--fail-on-error/--no-fail-on-error",
154
+ help="Exit with error if issues found (default: enabled)",
155
+ ),
156
+ ) -> None:
157
+ """Scan and optionally fix markdown table formatting issues.
158
+
159
+ By default, scans the current directory and reports issues without fixing.
160
+ Use --auto-fix to automatically fix issues found.
161
+
162
+ Examples:
163
+ markdown-table-fixer lint # Scan current directory
164
+ markdown-table-fixer lint --auto-fix # Scan and fix issues
165
+ markdown-table-fixer lint /path/to/docs # Scan specific path
166
+ markdown-table-fixer lint --check # CI mode: fail if issues found
167
+ """
168
+ # Use the positional path argument
169
+ scan_path = path
170
+
171
+ # Use auto_fix flag to determine if we should fix issues
172
+ should_fix = auto_fix
173
+
174
+ # Don't print status messages in JSON mode or when quiet
175
+ if not quiet and output_format != OutputFormat.JSON:
176
+ console.print(f"🔍 Scanning: {scan_path}")
177
+ if should_fix:
178
+ console.print("🔧 Auto-fix enabled")
179
+ console.print()
180
+
181
+ try:
182
+ scanner = MarkdownFileScanner(scan_path)
183
+ markdown_files = scanner.find_markdown_files()
184
+
185
+ if not markdown_files:
186
+ if not quiet:
187
+ console.print(
188
+ f"[yellow]No markdown files found in {scan_path}[/yellow]"
189
+ )
190
+ return
191
+
192
+ # Process files
193
+ results = []
194
+ for md_file in markdown_files:
195
+ result = _process_file(md_file, should_fix, max_line_length)
196
+ results.append(result)
197
+
198
+ # Create scan result
199
+ scan_result = ScanResult()
200
+ for result in results:
201
+ scan_result.add_file_result(result)
202
+
203
+ # Output results
204
+ if output_format == OutputFormat.JSON:
205
+ _output_json_results(scan_result)
206
+ else:
207
+ _output_text_results(scan_result, quiet)
208
+
209
+ # Exit with error if issues found and check mode
210
+ if (check or fail_on_error) and scan_result.files_with_issues > 0:
211
+ raise typer.Exit(1)
212
+
213
+ except Exception as e:
214
+ console.print(f"[red]Error:[/red] {e}")
215
+ raise typer.Exit(1) from e
216
+
217
+
218
+ @app.command() # type: ignore[misc]
219
+ @_add_version_to_docstring # type: ignore[misc]
220
+ def github(
221
+ target: str = typer.Argument(
222
+ ...,
223
+ help="GitHub organization name/URL or PR URL (e.g., https://github.com/org or https://github.com/owner/repo/pull/123)",
224
+ ),
225
+ token: str | None = typer.Option(
226
+ None,
227
+ "--token",
228
+ "-t",
229
+ help="GitHub token (or set GITHUB_TOKEN env var)",
230
+ envvar="GITHUB_TOKEN",
231
+ ),
232
+ sync_strategy: str = typer.Option(
233
+ "none",
234
+ "--sync-strategy",
235
+ help="How to sync PR with base branch: 'rebase', 'merge', or 'none' (default: none)",
236
+ case_sensitive=False,
237
+ ),
238
+ conflict_strategy: str = typer.Option(
239
+ "fail",
240
+ "--conflict-strategy",
241
+ help="How to resolve conflicts: 'fail', 'ours' (keep PR changes), or 'theirs' (keep base changes)",
242
+ case_sensitive=False,
243
+ ),
244
+ dry_run: bool = typer.Option(
245
+ False,
246
+ "--dry-run",
247
+ help="Preview changes without applying them",
248
+ ),
249
+ include_drafts: bool = typer.Option(
250
+ False,
251
+ "--include-drafts",
252
+ help="Include draft PRs in scan",
253
+ ),
254
+ debug_org: bool = typer.Option(
255
+ False,
256
+ "--debug-org",
257
+ help="Debug mode: only process first PR found (useful for testing org scans)",
258
+ ),
259
+ _workers: int = typer.Option(
260
+ 4,
261
+ "--workers",
262
+ "-j",
263
+ min=1,
264
+ max=32,
265
+ help="Number of parallel workers (default: 4)",
266
+ ),
267
+ verbose: bool = typer.Option(
268
+ False,
269
+ "--verbose",
270
+ "-v",
271
+ help="Enable verbose output",
272
+ ),
273
+ quiet: bool = typer.Option(
274
+ False,
275
+ "--quiet",
276
+ "-q",
277
+ help="Suppress all output except errors",
278
+ ),
279
+ log_level: str = typer.Option(
280
+ "INFO",
281
+ "--log-level",
282
+ help="Set logging level",
283
+ ),
284
+ _version: bool = typer.Option(
285
+ False,
286
+ "--version",
287
+ help="Show version and exit",
288
+ callback=version_callback,
289
+ is_eager=True,
290
+ ),
291
+ ) -> None:
292
+ """Fix markdown tables in GitHub PRs.
293
+
294
+ Can process either:
295
+ - An entire organization (scans all PRs for table issues)
296
+ - A specific PR by URL
297
+
298
+ Sync strategies:
299
+ - 'none': Fix tables on PR branch as-is (may have conflicts)
300
+ - 'rebase': Rebase PR onto base branch before fixing (cleaner history)
301
+ - 'merge': Merge base branch into PR before fixing (safer)
302
+
303
+ Conflict resolution strategies (when sync causes conflicts):
304
+ - 'fail': Abort if conflicts occur (default, safest)
305
+ - 'ours': Keep PR changes, discard conflicting base changes
306
+ - 'theirs': Keep base changes, discard conflicting PR changes
307
+
308
+ Examples:
309
+ markdown-table-fixer github myorg --token ghp_xxx
310
+ markdown-table-fixer github https://github.com/owner/repo/pull/123
311
+ markdown-table-fixer github https://github.com/owner/repo/pull/123 --sync-strategy rebase
312
+ markdown-table-fixer github https://github.com/owner/repo/pull/123 --sync-strategy merge --conflict-strategy ours
313
+ """
314
+ setup_logging(log_level=log_level, quiet=quiet, verbose=verbose)
315
+
316
+ # Validate sync strategy
317
+ if sync_strategy.lower() not in ["none", "rebase", "merge"]:
318
+ console.print(
319
+ f"[red]Error:[/red] Invalid sync strategy '{sync_strategy}'. "
320
+ "Use 'none', 'rebase', or 'merge'"
321
+ )
322
+ raise typer.Exit(1)
323
+
324
+ # Validate conflict strategy
325
+ if conflict_strategy.lower() not in ["fail", "ours", "theirs"]:
326
+ console.print(
327
+ f"[red]Error:[/red] Invalid conflict strategy '{conflict_strategy}'. "
328
+ "Use 'fail', 'ours', or 'theirs'"
329
+ )
330
+ raise typer.Exit(1)
331
+
332
+ if not token:
333
+ console.print(
334
+ "[red]Error:[/red] GitHub token required. "
335
+ "Provide --token or set GITHUB_TOKEN environment variable"
336
+ )
337
+ raise typer.Exit(1)
338
+
339
+ # Check if target is a PR URL or organization name
340
+ if "/pull/" in target or "/pulls/" in target:
341
+ # Single PR
342
+ asyncio.run(
343
+ _fix_single_pr(
344
+ target,
345
+ token,
346
+ sync_strategy=sync_strategy.lower(),
347
+ conflict_strategy=conflict_strategy.lower(),
348
+ dry_run=dry_run,
349
+ quiet=quiet,
350
+ )
351
+ )
352
+ else:
353
+ # Scan organization
354
+ asyncio.run(
355
+ _scan_organization(
356
+ target,
357
+ token,
358
+ sync_strategy=sync_strategy.lower(),
359
+ conflict_strategy=conflict_strategy.lower(),
360
+ dry_run=dry_run,
361
+ include_drafts=include_drafts,
362
+ debug_org=debug_org,
363
+ quiet=quiet,
364
+ )
365
+ )
366
+
367
+
368
+ async def _fix_single_pr(
369
+ pr_url: str,
370
+ token: str,
371
+ sync_strategy: str = "none",
372
+ conflict_strategy: str = "fail",
373
+ dry_run: bool = False,
374
+ quiet: bool = False,
375
+ ) -> None:
376
+ """Fix markdown tables in a single PR."""
377
+ if not quiet:
378
+ console.print(f"🔍 Processing PR: {pr_url}")
379
+
380
+ try:
381
+ async with GitHubClient(token) as client: # type: ignore[attr-defined]
382
+ fixer = PRFixer(client)
383
+ result = await fixer.fix_pr_by_url( # type: ignore[attr-defined]
384
+ pr_url,
385
+ sync_strategy=sync_strategy,
386
+ conflict_strategy=conflict_strategy,
387
+ dry_run=dry_run,
388
+ )
389
+
390
+ if result.success:
391
+ if not quiet:
392
+ console.print(f"[green]✅ {result.message}[/green]")
393
+ if result.files_modified:
394
+ for file in result.files_modified:
395
+ console.print(f" - {file}")
396
+ elif not quiet:
397
+ console.print(f"[yellow]⚠️ {result.message}[/yellow]")
398
+ if result.error:
399
+ console.print(f" Error: {result.error}")
400
+
401
+ except Exception as e:
402
+ console.print(f"[red]Error processing PR:[/red] {e}")
403
+ raise typer.Exit(1) from e
404
+
405
+
406
+ async def _scan_organization(
407
+ org: str,
408
+ token: str,
409
+ sync_strategy: str = "none",
410
+ conflict_strategy: str = "fail",
411
+ dry_run: bool = False,
412
+ include_drafts: bool = False,
413
+ debug_org: bool = False,
414
+ quiet: bool = False,
415
+ ) -> None:
416
+ """Scan organization for PRs with markdown table issues."""
417
+ # Remove github.com prefix if present
418
+ if "github.com" in org:
419
+ org = org.split("github.com/")[-1].strip("/")
420
+
421
+ if not quiet:
422
+ console.print(f"🔍 Scanning organization: {org}")
423
+
424
+ try:
425
+ async with GitHubClient(token) as client: # type: ignore[attr-defined]
426
+ # Create progress tracker for visual feedback
427
+ progress_tracker = (
428
+ None if quiet else ProgressTracker(org, show_pr_stats=True)
429
+ )
430
+
431
+ scanner = PRScanner(client, progress_tracker=progress_tracker)
432
+ fixer = PRFixer(client)
433
+
434
+ # Collect PRs from async generator
435
+ prs_to_process = []
436
+
437
+ if progress_tracker:
438
+ progress_tracker.start()
439
+
440
+ # Scanner yields only PRs that are blocked by markdown/lint check failures
441
+ # (it counts repos internally using GraphQL)
442
+ try:
443
+ async for (
444
+ owner,
445
+ repo_name,
446
+ pr_data,
447
+ ) in scanner.scan_organization(
448
+ org, include_drafts=include_drafts
449
+ ):
450
+ pr_info = PRInfo(
451
+ number=pr_data.get("number", 0),
452
+ title=pr_data.get("title", ""),
453
+ repository=f"{owner}/{repo_name}",
454
+ url=pr_data.get("html_url", ""),
455
+ author=pr_data.get("user", {}).get("login", ""),
456
+ is_draft=pr_data.get("draft", False),
457
+ head_ref=pr_data.get("head", {}).get("ref", ""),
458
+ head_sha=pr_data.get("head", {}).get("sha", ""),
459
+ base_ref=pr_data.get("base", {}).get("ref", ""),
460
+ mergeable=pr_data.get("mergeable_state", ""),
461
+ merge_state_status=pr_data.get(
462
+ "merge_state_status", ""
463
+ ),
464
+ )
465
+
466
+ blocked_pr = BlockedPR(
467
+ pr_info=pr_info,
468
+ blocking_reasons=["Failing markdown/lint checks"],
469
+ has_markdown_issues=True, # Will be verified when we try to fix
470
+ )
471
+
472
+ prs_to_process.append(blocked_pr)
473
+ except Exception as scan_error:
474
+ # Stop progress tracker on error
475
+ if progress_tracker:
476
+ progress_tracker.stop()
477
+ console.print(
478
+ f"\n[yellow]⚠️ Scanning interrupted: {scan_error}[/yellow]"
479
+ )
480
+ console.print("[yellow]Processing PRs found so far...[/yellow]")
481
+
482
+ # Stop progress tracker
483
+ if progress_tracker:
484
+ progress_tracker.stop()
485
+
486
+ if not quiet:
487
+ console.print(
488
+ f"\n📊 Found {len(prs_to_process)} blocked PRs with potential markdown issues"
489
+ )
490
+
491
+ if not prs_to_process:
492
+ console.print(
493
+ "\n[green]✅ No blocked PRs with markdown table issues found![/green]"
494
+ )
495
+ return
496
+
497
+ # Display PRs
498
+ if not quiet:
499
+ console.print(
500
+ "\n🔍 Blocked PRs with potential markdown issues:"
501
+ )
502
+ for blocked_pr in prs_to_process:
503
+ console.print(
504
+ f" • {blocked_pr.pr_info.repository}#{blocked_pr.pr_info.number}: {blocked_pr.pr_info.title}"
505
+ )
506
+
507
+ # Debug mode: limit to first PR only
508
+ if debug_org and prs_to_process:
509
+ if not quiet:
510
+ console.print(
511
+ "\n[yellow]🐛 GitHub Organisation DEBUG mode: only processing first pull request[/yellow]"
512
+ )
513
+ prs_to_process = prs_to_process[:1]
514
+ elif not quiet:
515
+ # Only show this message if NOT in debug mode
516
+ if dry_run:
517
+ console.print(
518
+ f"\n🔍 [DRY RUN] Would fix {len(prs_to_process)} PRs (no changes will be made)..."
519
+ )
520
+ else:
521
+ console.print(f"\n🔧 Fixing {len(prs_to_process)} PRs...")
522
+
523
+ prs_fixed = 0
524
+ prs_with_issues = 0
525
+ fixed_pr_urls = []
526
+
527
+ # Process PRs sequentially to avoid overwhelming the system
528
+ for blocked_pr in prs_to_process:
529
+ try:
530
+ if not quiet:
531
+ console.print(
532
+ f"\n🔄 Processing: {blocked_pr.pr_info.repository}#{blocked_pr.pr_info.number}"
533
+ )
534
+
535
+ result = await fixer.fix_pr_by_url(
536
+ blocked_pr.pr_info.url,
537
+ sync_strategy=sync_strategy,
538
+ conflict_strategy=conflict_strategy,
539
+ dry_run=dry_run,
540
+ )
541
+
542
+ if result.success:
543
+ if len(result.files_modified) > 0:
544
+ prs_fixed += 1
545
+ fixed_pr_urls.append(blocked_pr.pr_info.url)
546
+ if not quiet:
547
+ console.print(f"[green]✅ {result.message}[/green]")
548
+ elif not quiet:
549
+ console.print(f"[yellow]⚠️ {result.message}[/yellow]")
550
+
551
+ if len(result.files_modified) > 0:
552
+ prs_with_issues += 1
553
+
554
+ except Exception as e:
555
+ if not quiet:
556
+ console.print(f"[red]❌ Error: {e}[/red]")
557
+
558
+ if dry_run:
559
+ console.print(
560
+ f"\n[green]✅ [DRY RUN] Found issues in {prs_with_issues} PR(s) (no changes made)[/green]"
561
+ )
562
+ elif prs_fixed > 0:
563
+ console.print(f"\n[green]✅ Fixed {prs_fixed} PR(s):[/green]")
564
+ for pr_url in fixed_pr_urls:
565
+ console.print(f" • {pr_url}")
566
+ else:
567
+ console.print("\n[green]✅ No PRs needed fixing[/green]")
568
+
569
+ except Exception as e:
570
+ console.print(f"[red]Error scanning organization:[/red] {e}")
571
+ if not quiet:
572
+ import traceback
573
+
574
+ console.print("[dim]" + traceback.format_exc() + "[/dim]")
575
+ raise typer.Exit(1) from e
576
+
577
+
578
+ def _process_file(
579
+ file_path: Path, fix: bool = False, max_line_length: int = 80
580
+ ) -> FileResult:
581
+ """Process a single markdown file.
582
+
583
+ Args:
584
+ file_path: Path to the file
585
+ fix: Whether to fix issues
586
+ max_line_length: Maximum line length before adding MD013 disable
587
+
588
+ Returns:
589
+ File processing result
590
+ """
591
+ result = FileResult(file_path=file_path)
592
+
593
+ try:
594
+ # Parse tables from file
595
+ parser = TableParser(file_path)
596
+ tables = parser.parse_file()
597
+
598
+ result.tables_found = len(tables)
599
+
600
+ # Validate each table
601
+ for table in tables:
602
+ validator = TableValidator(table)
603
+ violations = validator.validate()
604
+ result.violations.extend(violations)
605
+
606
+ # Fix if requested
607
+ # Always run fixer if fix=True to add MD013 comments even when no violations
608
+ if fix:
609
+ fixer = FileFixer(file_path, max_line_length=max_line_length)
610
+ fixes = fixer.fix_file(tables)
611
+ result.fixes_applied.extend(fixes)
612
+
613
+ except (FileAccessError, TableParseError) as e:
614
+ result.error = str(e)
615
+
616
+ return result
617
+
618
+
619
+ def _output_json_results(result: ScanResult) -> None:
620
+ """Output results in JSON format."""
621
+ output = {
622
+ "files_scanned": result.files_scanned,
623
+ "files_with_issues": result.files_with_issues,
624
+ "files_fixed": result.files_fixed,
625
+ "total_violations": result.total_violations,
626
+ "total_fixes": result.total_fixes,
627
+ "files": [
628
+ {
629
+ "path": str(fr.file_path),
630
+ "tables_found": fr.tables_found,
631
+ "violations": len(fr.violations),
632
+ "fixes_applied": len(fr.fixes_applied),
633
+ "error": fr.error,
634
+ }
635
+ for fr in result.file_results
636
+ ],
637
+ }
638
+ console.print(json.dumps(output, indent=2))
639
+
640
+
641
+ def _output_text_results(result: ScanResult, quiet: bool) -> None:
642
+ """Output results in text format."""
643
+ if quiet:
644
+ return
645
+
646
+ if result.files_with_issues == 0:
647
+ console.print("[green]✅ No issues found![/green]")
648
+ return
649
+
650
+ # Summary
651
+ console.print("\n[bold]Summary:[/bold]")
652
+ console.print(f" Files scanned: {result.files_scanned}")
653
+ console.print(f" Files with issues: {result.files_with_issues}")
654
+ if result.files_fixed > 0:
655
+ console.print(f" Files fixed: {result.files_fixed}")
656
+ console.print(f" Total violations: {result.total_violations}")
657
+ if result.total_fixes > 0:
658
+ console.print(f" Total fixes: {result.total_fixes}")
659
+ console.print()
660
+
661
+ # Show errors
662
+ if result.errors:
663
+ console.print("[bold red]Errors:[/bold red]")
664
+ for error in result.errors[:5]:
665
+ console.print(f" {error}")
666
+ if len(result.errors) > 5:
667
+ console.print(f" ... and {len(result.errors) - 5} more errors")
668
+ console.print()
669
+
670
+ # Show sample violations
671
+ if result.files_with_issues > 0:
672
+ console.print("[bold yellow]Files with issues:[/bold yellow]")
673
+ for file_result in result.file_results[:3]:
674
+ if file_result.has_violations:
675
+ console.print(f"{file_result.file_path}")
676
+
677
+ # Group violations by table
678
+ violations_by_table: dict[int, list[TableViolation]] = {}
679
+ for violation in file_result.violations:
680
+ table_line = violation.table_start_line
681
+ if table_line not in violations_by_table:
682
+ violations_by_table[table_line] = []
683
+ violations_by_table[table_line].append(violation)
684
+
685
+ # Show summary per table
686
+ for table_line in sorted(violations_by_table.keys()):
687
+ violations = violations_by_table[table_line]
688
+ # Count unique rows with violations
689
+ unique_rows = len({v.line_number for v in violations})
690
+ console.print(
691
+ f" Markdown table at line {table_line} has {unique_rows} "
692
+ f"row(s) with formatting issues"
693
+ )
694
+ console.print()
695
+
696
+ if result.files_with_issues > 3:
697
+ console.print(
698
+ f"... and {result.files_with_issues - 3} more files with issues"
699
+ )
700
+
701
+
702
+ if __name__ == "__main__":
703
+ app()