code2docs 3.0.29__tar.gz → 3.0.33__tar.gz

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.
Files changed (68) hide show
  1. {code2docs-3.0.29 → code2docs-3.0.33}/PKG-INFO +8 -8
  2. {code2docs-3.0.29 → code2docs-3.0.33}/README.md +7 -7
  3. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/__init__.py +1 -1
  4. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/analyzers/__init__.py +10 -0
  5. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/analyzers/dependency_scanner.py +26 -1
  6. code2docs-3.0.33/code2docs/analyzers/markdown_validator.py +194 -0
  7. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/cli.py +30 -0
  8. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/config.py +1 -1
  9. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/formatters/badges.py +20 -0
  10. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/examples_gen.py +6 -34
  11. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/readme_gen.py +44 -8
  12. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/templates/readme.md.j2 +108 -27
  13. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs.egg-info/PKG-INFO +8 -8
  14. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs.egg-info/SOURCES.txt +2 -0
  15. {code2docs-3.0.29 → code2docs-3.0.33}/pyproject.toml +1 -1
  16. code2docs-3.0.33/tests/test_markdown_validator.py +75 -0
  17. {code2docs-3.0.29 → code2docs-3.0.33}/LICENSE +0 -0
  18. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/__main__.py +0 -0
  19. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/analyzers/docstring_extractor.py +0 -0
  20. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/analyzers/endpoint_detector.py +0 -0
  21. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/analyzers/project_scanner.py +0 -0
  22. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/base.py +0 -0
  23. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/examples/advanced_usage.py +0 -0
  24. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/examples/quickstart.py +0 -0
  25. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/formatters/__init__.py +0 -0
  26. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/formatters/markdown.py +0 -0
  27. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/formatters/toc.py +0 -0
  28. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/__init__.py +0 -0
  29. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/_registry_adapters.py +0 -0
  30. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/_source_links.py +0 -0
  31. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/api_changelog_gen.py +0 -0
  32. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/api_reference_gen.py +0 -0
  33. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/architecture_gen.py +0 -0
  34. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/changelog_gen.py +0 -0
  35. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/code2llm_gen.py +0 -0
  36. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/config_docs_gen.py +0 -0
  37. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/contributing_gen.py +0 -0
  38. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/coverage_gen.py +0 -0
  39. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/depgraph_gen.py +0 -0
  40. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/getting_started_gen.py +0 -0
  41. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/mkdocs_gen.py +0 -0
  42. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/module_docs_gen.py +0 -0
  43. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/generators/org_readme_gen.py +0 -0
  44. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/llm_helper.py +0 -0
  45. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/registry.py +0 -0
  46. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/sync/__init__.py +0 -0
  47. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/sync/differ.py +0 -0
  48. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/sync/updater.py +0 -0
  49. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/sync/watcher.py +0 -0
  50. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/templates/api_module.md.j2 +0 -0
  51. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/templates/architecture.md.j2 +0 -0
  52. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/templates/example_usage.py.j2 +0 -0
  53. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/templates/index.md.j2 +0 -0
  54. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs/templates/module_doc.md.j2 +0 -0
  55. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs.egg-info/dependency_links.txt +0 -0
  56. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs.egg-info/entry_points.txt +0 -0
  57. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs.egg-info/requires.txt +0 -0
  58. {code2docs-3.0.29 → code2docs-3.0.33}/code2docs.egg-info/top_level.txt +0 -0
  59. {code2docs-3.0.29 → code2docs-3.0.33}/setup.cfg +0 -0
  60. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_analyzers.py +0 -0
  61. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_cli.py +0 -0
  62. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_code2docs.py +0 -0
  63. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_config.py +0 -0
  64. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_formatters.py +0 -0
  65. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_generators.py +0 -0
  66. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_llm_helper.py +0 -0
  67. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_registry.py +0 -0
  68. {code2docs-3.0.29 → code2docs-3.0.33}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code2docs
3
- Version: 3.0.29
3
+ Version: 3.0.33
4
4
  Summary: Auto-generate and sync project documentation from source code analysis
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
6
  License-Expression: Apache-2.0
@@ -53,17 +53,17 @@ Dynamic: license-file
53
53
 
54
54
  ## AI Cost Tracking
55
55
 
56
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-3.0.29-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
57
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
56
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-3.0.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
57
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
58
58
 
59
- - 🤖 **LLM usage:** $7.5000 (68 commits)
60
- - 👤 **Human dev:** ~$2384 (23.8h @ $100/h, 30min dedup)
59
+ - 🤖 **LLM usage:** $7.5000 (73 commits)
60
+ - 👤 **Human dev:** ~$2534 (25.3h @ $100/h, 30min dedup)
61
61
 
62
- Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
62
+ Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
63
63
 
64
64
  ---
65
65
 
66
- ![version](https://img.shields.io/badge/version-3.0.29-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
66
+ ![version](https://img.shields.io/badge/version-3.0.33-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
67
67
 
68
68
  > Auto-generate and sync project documentation from source code analysis.
69
69
 
@@ -200,7 +200,7 @@ code2docs can update only specific sections of an existing README using markers:
200
200
  ```markdown
201
201
  <!-- code2docs:start --># code2docs
202
202
 
203
- ![version](https://img.shields.io/badge/version-3.0.29-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
203
+ ![version](https://img.shields.io/badge/version-3.0.33-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
204
204
  > **276** functions | **57** classes | **51** files | CC̄ = 3.8
205
205
 
206
206
  > Auto-generated project documentation from source code analysis.
@@ -1,16 +1,16 @@
1
1
  ## AI Cost Tracking
2
2
 
3
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-3.0.29-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
4
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
3
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-3.0.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
4
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
5
5
 
6
- - 🤖 **LLM usage:** $7.5000 (68 commits)
7
- - 👤 **Human dev:** ~$2384 (23.8h @ $100/h, 30min dedup)
6
+ - 🤖 **LLM usage:** $7.5000 (73 commits)
7
+ - 👤 **Human dev:** ~$2534 (25.3h @ $100/h, 30min dedup)
8
8
 
9
- Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
9
+ Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
10
10
 
11
11
  ---
12
12
 
13
- ![version](https://img.shields.io/badge/version-3.0.29-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
13
+ ![version](https://img.shields.io/badge/version-3.0.33-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
14
14
 
15
15
  > Auto-generate and sync project documentation from source code analysis.
16
16
 
@@ -147,7 +147,7 @@ code2docs can update only specific sections of an existing README using markers:
147
147
  ```markdown
148
148
  <!-- code2docs:start --># code2docs
149
149
 
150
- ![version](https://img.shields.io/badge/version-3.0.29-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
150
+ ![version](https://img.shields.io/badge/version-3.0.33-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
151
151
  > **276** functions | **57** classes | **51** files | CC̄ = 3.8
152
152
 
153
153
  > Auto-generated project documentation from source code analysis.
@@ -5,7 +5,7 @@ Uses code2llm's AnalysisResult to produce human-readable documentation:
5
5
  README.md, API references, module docs, examples, and architecture diagrams.
6
6
  """
7
7
 
8
- __version__ = '3.0.29'
8
+ __version__ = '3.0.33'
9
9
  __author__ = 'Tom Sapletta'
10
10
  __all__ = ['Code2DocsConfig', 'generate_readme', 'generate_docs', 'analyze_and_document']
11
11
 
@@ -4,10 +4,20 @@ from .project_scanner import ProjectScanner
4
4
  from .endpoint_detector import EndpointDetector
5
5
  from .docstring_extractor import DocstringExtractor
6
6
  from .dependency_scanner import DependencyScanner
7
+ from .markdown_validator import (
8
+ MarkdownIssue,
9
+ ValidationReport,
10
+ validate_markdown_file,
11
+ validate_markdown_tree,
12
+ )
7
13
 
8
14
  __all__ = [
9
15
  "ProjectScanner",
10
16
  "EndpointDetector",
11
17
  "DocstringExtractor",
12
18
  "DependencyScanner",
19
+ "MarkdownIssue",
20
+ "ValidationReport",
21
+ "validate_markdown_file",
22
+ "validate_markdown_tree",
13
23
  ]
@@ -1,4 +1,4 @@
1
- """Scan project dependencies from requirements.txt, pyproject.toml, setup.py, package.json, Cargo.toml, go.mod."""
1
+ """Scan project dependencies from requirements.txt, pyproject.toml, setup.py, package.json, composer.json, Cargo.toml, go.mod."""
2
2
  import re
3
3
  from dataclasses import dataclass, field
4
4
  from pathlib import Path
@@ -50,6 +50,11 @@ class DependencyScanner:
50
50
  if tsconfig.exists():
51
51
  deps.language = 'typescript'
52
52
  return deps
53
+ composer_json = project / 'composer.json'
54
+ if composer_json.exists():
55
+ deps = self._parse_composer_json(composer_json)
56
+ deps.source_file = 'composer.json'
57
+ return deps
53
58
  cargo_toml = project / 'Cargo.toml'
54
59
  if cargo_toml.exists():
55
60
  deps = self._parse_cargo_toml(cargo_toml)
@@ -171,6 +176,26 @@ class DependencyScanner:
171
176
  deps.keywords = data.get('keywords', [])
172
177
  return deps
173
178
 
179
+ def _parse_composer_json(self, path: Path) -> ProjectDependencies:
180
+ """Parse composer.json for PHP dependencies."""
181
+ import json
182
+ deps = ProjectDependencies(language='php')
183
+ try:
184
+ data = json.loads(path.read_text(encoding='utf-8'))
185
+ except (json.JSONDecodeError, OSError):
186
+ return deps
187
+ deps.version = data.get('version', '')
188
+ deps.keywords = data.get('keywords', []) or []
189
+ for dep_name, ver in (data.get('require', {}) or {}).items():
190
+ if dep_name.lower() == 'php':
191
+ deps.runtime_version = ver
192
+ continue
193
+ deps.dependencies.append(DependencyInfo(name=dep_name, version_spec=ver))
194
+ for dep_name, ver in (data.get('require-dev', {}) or {}).items():
195
+ deps.dev_dependencies.append(DependencyInfo(name=dep_name, version_spec=ver, group='dev'))
196
+ deps.install_command = 'composer install'
197
+ return deps
198
+
174
199
  def _parse_cargo_toml(self, path: Path) -> ProjectDependencies:
175
200
  """Parse Cargo.toml for Rust dependencies."""
176
201
  deps = ProjectDependencies(language='rust')
@@ -0,0 +1,194 @@
1
+ """Validate generated markdown: checks local links/images, table shape, duplicate headings."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Iterable, List, Optional
8
+ from urllib.parse import urlparse
9
+
10
+ _LINK_RE = re.compile(r'!?\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)')
11
+ _HEADING_RE = re.compile(r'^(#{1,6})\s+(.+?)\s*#*\s*$')
12
+ _FENCE_RE = re.compile(r'^(```|~~~)')
13
+ _TABLE_SEP_RE = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')
14
+
15
+
16
+ @dataclass
17
+ class MarkdownIssue:
18
+ """A single validation issue in a markdown file."""
19
+ file: str
20
+ line: int
21
+ kind: str
22
+ message: str
23
+
24
+ def __str__(self) -> str:
25
+ return f'{self.file}:{self.line} [{self.kind}] {self.message}'
26
+
27
+
28
+ @dataclass
29
+ class ValidationReport:
30
+ """Aggregate result of markdown validation."""
31
+ issues: List[MarkdownIssue] = field(default_factory=list)
32
+ files_checked: int = 0
33
+
34
+ @property
35
+ def ok(self) -> bool:
36
+ return not self.issues
37
+
38
+ def by_kind(self, kind: str) -> List[MarkdownIssue]:
39
+ return [i for i in self.issues if i.kind == kind]
40
+
41
+ def summary(self) -> str:
42
+ if self.ok:
43
+ return f'✅ {self.files_checked} file(s) checked, no issues'
44
+ kinds: dict = {}
45
+ for issue in self.issues:
46
+ kinds[issue.kind] = kinds.get(issue.kind, 0) + 1
47
+ parts = ', '.join(f'{k}={v}' for k, v in sorted(kinds.items()))
48
+ return f'❌ {len(self.issues)} issue(s) across {self.files_checked} file(s): {parts}'
49
+
50
+
51
+ def validate_markdown_file(md_path: Path, project_root: Optional[Path] = None) -> List[MarkdownIssue]:
52
+ """Validate a single markdown file.
53
+
54
+ Checks:
55
+ - Local links/images resolve to existing files (skips URLs and in-page anchors).
56
+ - Tables have consistent column count.
57
+ - Duplicate headings at the same level are flagged.
58
+ """
59
+ md_path = Path(md_path)
60
+ if not md_path.exists():
61
+ return [MarkdownIssue(str(md_path), 0, 'missing', 'file does not exist')]
62
+ root = Path(project_root) if project_root else md_path.parent
63
+ try:
64
+ lines = md_path.read_text(encoding='utf-8').splitlines()
65
+ except OSError as exc:
66
+ return [MarkdownIssue(str(md_path), 0, 'read_error', str(exc))]
67
+ issues: List[MarkdownIssue] = []
68
+ issues.extend(_check_links(md_path, root, lines))
69
+ issues.extend(_check_tables(md_path, lines))
70
+ issues.extend(_check_headings(md_path, lines))
71
+ return issues
72
+
73
+
74
+ def validate_markdown_tree(root: Path, patterns: Iterable[str] = ('*.md',),
75
+ ignore: Iterable[str] = ('node_modules', 'venv', '.venv',
76
+ 'vendor', '.git', '__pycache__')) -> ValidationReport:
77
+ """Validate every markdown file under ``root`` matching ``patterns``."""
78
+ root = Path(root)
79
+ ignore_set = set(ignore)
80
+ report = ValidationReport()
81
+ seen: set = set()
82
+ for pattern in patterns:
83
+ for md_file in root.rglob(pattern):
84
+ if any(part in ignore_set for part in md_file.parts):
85
+ continue
86
+ if md_file in seen:
87
+ continue
88
+ seen.add(md_file)
89
+ report.files_checked += 1
90
+ report.issues.extend(validate_markdown_file(md_file, project_root=root))
91
+ return report
92
+
93
+
94
+ def _strip_code_fences(lines: List[str]) -> List[bool]:
95
+ """Return a per-line boolean: True if the line is inside a fenced code block."""
96
+ inside = False
97
+ mask: List[bool] = []
98
+ for line in lines:
99
+ if _FENCE_RE.match(line.strip()):
100
+ mask.append(True)
101
+ inside = not inside
102
+ continue
103
+ mask.append(inside)
104
+ return mask
105
+
106
+
107
+ def _check_links(md_path: Path, root: Path, lines: List[str]) -> List[MarkdownIssue]:
108
+ issues: List[MarkdownIssue] = []
109
+ in_code = _strip_code_fences(lines)
110
+ for lineno, line in enumerate(lines, 1):
111
+ if in_code[lineno - 1]:
112
+ continue
113
+ for match in _LINK_RE.finditer(line):
114
+ target = match.group(2).strip()
115
+ if not target or target.startswith('#'):
116
+ continue
117
+ parsed = urlparse(target)
118
+ if parsed.scheme in ('http', 'https', 'mailto', 'ftp', 'tel', 'data'):
119
+ continue
120
+ if parsed.scheme and parsed.scheme not in ('file',):
121
+ continue
122
+ rel = parsed.path
123
+ if not rel:
124
+ continue
125
+ if rel.startswith('/'):
126
+ resolved = root / rel.lstrip('/')
127
+ else:
128
+ resolved = (md_path.parent / rel).resolve()
129
+ if not resolved.exists():
130
+ issues.append(MarkdownIssue(
131
+ str(md_path), lineno, 'broken_link',
132
+ f'link target not found: {target}'))
133
+ return issues
134
+
135
+
136
+ def _check_tables(md_path: Path, lines: List[str]) -> List[MarkdownIssue]:
137
+ issues: List[MarkdownIssue] = []
138
+ in_code = _strip_code_fences(lines)
139
+ i = 0
140
+ while i < len(lines):
141
+ if in_code[i]:
142
+ i += 1
143
+ continue
144
+ line = lines[i]
145
+ if '|' in line and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]):
146
+ header_cols = _count_cells(line)
147
+ sep_cols = _count_cells(lines[i + 1])
148
+ if header_cols != sep_cols:
149
+ issues.append(MarkdownIssue(
150
+ str(md_path), i + 2, 'table_shape',
151
+ f'separator has {sep_cols} cells, header has {header_cols}'))
152
+ j = i + 2
153
+ while j < len(lines) and not in_code[j] and lines[j].strip() and '|' in lines[j]:
154
+ cells = _count_cells(lines[j])
155
+ if cells != header_cols:
156
+ issues.append(MarkdownIssue(
157
+ str(md_path), j + 1, 'table_shape',
158
+ f'row has {cells} cells, expected {header_cols}'))
159
+ j += 1
160
+ i = j
161
+ else:
162
+ i += 1
163
+ return issues
164
+
165
+
166
+ def _count_cells(row: str) -> int:
167
+ stripped = row.strip()
168
+ if stripped.startswith('|'):
169
+ stripped = stripped[1:]
170
+ if stripped.endswith('|'):
171
+ stripped = stripped[:-1]
172
+ return len(stripped.split('|'))
173
+
174
+
175
+ def _check_headings(md_path: Path, lines: List[str]) -> List[MarkdownIssue]:
176
+ issues: List[MarkdownIssue] = []
177
+ in_code = _strip_code_fences(lines)
178
+ seen: dict = {}
179
+ for lineno, line in enumerate(lines, 1):
180
+ if in_code[lineno - 1]:
181
+ continue
182
+ match = _HEADING_RE.match(line)
183
+ if not match:
184
+ continue
185
+ level = len(match.group(1))
186
+ title = match.group(2).strip().lower()
187
+ key = (level, title)
188
+ if key in seen:
189
+ issues.append(MarkdownIssue(
190
+ str(md_path), lineno, 'duplicate_heading',
191
+ f'H{level} "{match.group(2).strip()}" already at line {seen[key]}'))
192
+ else:
193
+ seen[key] = lineno
194
+ return issues
@@ -93,6 +93,36 @@ def check(project_path, config_path, target) -> None:
93
93
  config = _load_config(project_path, config_path)
94
94
  _run_check(project_path, config, target)
95
95
 
96
+ @main.command()
97
+ @click.argument('project_path', default='.', type=click.Path(exists=True))
98
+ @click.option('--pattern', multiple=True, default=('*.md',), show_default=True,
99
+ help='Glob pattern(s) of markdown files to validate.')
100
+ @click.option('--strict', is_flag=True, help='Exit with non-zero status if any issues are found.')
101
+ def validate(project_path, pattern, strict) -> None:
102
+ """Validate generated markdown: broken links, table shape, duplicate headings."""
103
+ from .analyzers.markdown_validator import validate_markdown_tree
104
+ project = Path(project_path).resolve()
105
+ console.print(f'[bold blue]🔎 code2docs validate:[/] {project.name}')
106
+ report = validate_markdown_tree(project, patterns=pattern)
107
+ if report.ok:
108
+ console.print(f'[green]{report.summary()}[/]')
109
+ return
110
+ table = Table(title='Markdown Issues', show_lines=False)
111
+ table.add_column('File', style='cyan', overflow='fold')
112
+ table.add_column('Line', justify='right')
113
+ table.add_column('Kind', style='magenta')
114
+ table.add_column('Message', style='dim', overflow='fold')
115
+ for issue in report.issues:
116
+ try:
117
+ rel = Path(issue.file).relative_to(project)
118
+ except ValueError:
119
+ rel = Path(issue.file)
120
+ table.add_row(str(rel), str(issue.line), issue.kind, issue.message)
121
+ console.print(table)
122
+ console.print(f'[yellow]{report.summary()}[/]')
123
+ if strict:
124
+ sys.exit(1)
125
+
96
126
  @main.command()
97
127
  @click.argument('project_path', default='.', type=click.Path(exists=True))
98
128
  @click.option('--config', '-c', 'config_path', default=None, help='Path to code2docs.yaml')
@@ -14,7 +14,7 @@ except ImportError:
14
14
  @dataclass
15
15
  class ReadmeConfig:
16
16
  """Configuration for README generation."""
17
- sections: List[str] = field(default_factory=lambda: ['overview', 'install', 'quickstart', 'generated_output', 'config', 'sync_markers', 'architecture', 'api', 'structure', 'requirements', 'contributing', 'docs_nav'])
17
+ sections: List[str] = field(default_factory=lambda: ['overview', 'install', 'quickstart', 'architecture', 'api', 'structure', 'requirements', 'endpoints', 'contributing', 'docs_nav'])
18
18
  badges: List[str] = field(default_factory=lambda: ['version', 'python', 'coverage', 'complexity'])
19
19
  sync_markers: bool = True
20
20
 
@@ -27,6 +27,9 @@ def _make_badge(badge_type: str, project_name: str,
27
27
  return f"![version](https://img.shields.io/badge/version-0.1.0-blue)"
28
28
 
29
29
  elif badge_type == "python":
30
+ lang = getattr(deps, "language", "python") if deps else "python"
31
+ if lang and lang != "python":
32
+ return _runtime_badge(lang, deps)
30
33
  py_version = ""
31
34
  if deps and hasattr(deps, "python_version"):
32
35
  py_version = deps.python_version
@@ -50,3 +53,20 @@ def _make_badge(badge_type: str, project_name: str,
50
53
  return f"![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)"
51
54
 
52
55
  return None
56
+
57
+
58
+ _RUNTIME_LABELS = {
59
+ "php": ("php", "777BB4"),
60
+ "javascript": ("node", "339933"),
61
+ "typescript": ("typescript", "3178C6"),
62
+ "rust": ("rust", "B7410E"),
63
+ "go": ("go", "00ADD8"),
64
+ }
65
+
66
+
67
+ def _runtime_badge(lang: str, deps) -> Optional[str]:
68
+ """Generate a runtime badge for non-Python languages."""
69
+ label, color = _RUNTIME_LABELS.get(lang, (lang, "blue"))
70
+ version = getattr(deps, "runtime_version", "") if deps else ""
71
+ version = version or "any"
72
+ return f"![{label}](https://img.shields.io/badge/{quote(label)}-{quote(version)}-{color})"
@@ -1,33 +1,3 @@
1
-
2
- PORT_4 = 4
3
- CONSTANT_5 = 5
4
- CONSTANT_50 = 50
5
-
6
-
7
- PORT_4 = PORT_4
8
- CONSTANT_5 = CONSTANT_5
9
- CONSTANT_50 = CONSTANT_50
10
-
11
-
12
- PORT_4 = PORT_4
13
- CONSTANT_5 = CONSTANT_5
14
- CONSTANT_50 = CONSTANT_50
15
-
16
-
17
- PORT_4 = PORT_4
18
- CONSTANT_5 = CONSTANT_5
19
- CONSTANT_50 = CONSTANT_50
20
-
21
-
22
-
23
- PORT_4 = PORT_4
24
- CONSTANT_5 = CONSTANT_5
25
- CONSTANT_50 = CONSTANT_50
26
-
27
- PORT_4 = PORT_4
28
- CONSTANT_5 = CONSTANT_5
29
- CONSTANT_50 = CONSTANT_50
30
-
31
1
  """Auto-generate usage examples from public signatures and entry points."""
32
2
 
33
3
  from pathlib import Path
@@ -37,10 +7,12 @@ from code2llm.api import AnalysisResult, FunctionInfo, ClassInfo
37
7
 
38
8
  from ..config import Code2DocsConfig
39
9
 
40
- # Default type hints → example values
10
+ PORT_4 = 4
11
+ CONSTANT_5 = 5
12
+ CONSTANT_50 = 50
41
13
 
42
- if __name__ == "__main__":
43
- _TYPE_EXAMPLES = {
14
+ # Default type hints → example values
15
+ _TYPE_EXAMPLES = {
44
16
  "str": '"./my-project"',
45
17
  "Path": 'Path("./my-project")',
46
18
  "int": "10",
@@ -55,7 +27,7 @@ if __name__ == "__main__":
55
27
  }
56
28
 
57
29
  # Arg name → realistic example value
58
- _ARG_EXAMPLES = {
30
+ _ARG_EXAMPLES = {
59
31
  "project_path": '"./my-project"',
60
32
  "path": '"./my-project"',
61
33
  "source": '"./src"',
@@ -57,7 +57,38 @@ class ReadmeGenerator:
57
57
  project_description = self._generate_description(project_name, entry_points)
58
58
  metadata = self._extract_project_metadata()
59
59
  extras = self._extract_extras()
60
- return {'project_name': project_name, 'project_path': self.result.project_path, 'project_description': project_description, 'badges': generate_badges(project_name, self.config.readme.badges, stats, deps), 'stats': stats, 'avg_complexity': avg_complexity, 'dependencies': deps, 'endpoints': endpoints, 'public_functions': public_functions, 'public_classes': public_classes, 'entry_points': entry_points, 'module_tree': module_tree, 'modules': self.result.modules, 'sync_markers': self.config.readme.sync_markers, 'author': metadata.get('author', ''), 'license': metadata.get('license', ''), 'license_file': metadata.get('license_file', ''), 'contributors': metadata.get('contributors', []), 'repo_url': self.config.repo_url, 'version': metadata.get('version', '0.1.0'), 'extras': extras}
60
+ lang = getattr(deps, 'language', 'python') or 'python'
61
+ docs_nav_items, generated_files = self._collect_existing_docs()
62
+ has_contributing = (Path(self.result.project_path) / 'CONTRIBUTING.md').exists()
63
+ return {'docs_nav_items': docs_nav_items, 'generated_files': generated_files, 'has_contributing': has_contributing, 'project_name': project_name, 'project_path': self.result.project_path, 'project_description': project_description, 'badges': generate_badges(project_name, self.config.readme.badges, stats, deps), 'stats': stats, 'avg_complexity': avg_complexity, 'dependencies': deps, 'language': lang, 'endpoints': endpoints, 'public_functions': public_functions, 'public_classes': public_classes, 'entry_points': entry_points, 'module_tree': module_tree, 'modules': self.result.modules, 'sync_markers': self.config.readme.sync_markers, 'author': metadata.get('author', ''), 'license': metadata.get('license', ''), 'license_file': metadata.get('license_file', ''), 'contributors': metadata.get('contributors', []), 'repo_url': self.config.repo_url, 'version': metadata.get('version', '0.1.0'), 'extras': extras}
64
+
65
+ _DOC_FILE_SPECS = [
66
+ ('docs/getting-started.md', 'Getting Started', '🚀', 'Quick start guide'),
67
+ ('docs/api.md', 'API Reference', '📚', 'Complete API documentation'),
68
+ ('docs/modules.md', 'Module Reference', '📦', 'Module reference with metrics'),
69
+ ('docs/architecture.md', 'Architecture', '🏛️', 'Architecture with diagrams'),
70
+ ('docs/dependency-graph.md', 'Dependency Graph', '🔗', 'Module dependency graphs'),
71
+ ('docs/coverage.md', 'Coverage', '📊', 'Docstring coverage report'),
72
+ ('docs/configuration.md', 'Configuration', '🔧', 'Configuration reference'),
73
+ ('docs/api-changelog.md', 'API Changelog', '📝', 'API change tracking'),
74
+ ('CONTRIBUTING.md', 'Contributing', '🤝', 'Contribution guidelines'),
75
+ ('examples', 'Examples', '💡', 'Usage examples and code samples'),
76
+ ('mkdocs.yml', 'MkDocs Config', '⚙️', 'MkDocs site configuration'),
77
+ ]
78
+
79
+ def _collect_existing_docs(self) -> tuple:
80
+ """Return (docs_nav_items, generated_files) only for files/dirs that exist."""
81
+ project = Path(self.result.project_path)
82
+ nav_items = []
83
+ generated = []
84
+ for rel_path, title, icon, description in self._DOC_FILE_SPECS:
85
+ target = project / rel_path
86
+ if not target.exists():
87
+ continue
88
+ link = f'./{rel_path}'
89
+ nav_items.append({'title': title, 'icon': icon, 'path': link, 'description': description})
90
+ generated.append({'output': rel_path, 'description': description, 'link': link})
91
+ return nav_items, generated
61
92
 
62
93
  def _calc_avg_complexity(self) -> float:
63
94
  """Calculate average cyclomatic complexity."""
@@ -179,14 +210,19 @@ class ReadmeGenerator:
179
210
  pass
180
211
 
181
212
  def _detect_license(self, metadata: Dict) -> None:
182
- """Detect license type from LICENSE files."""
183
- license_paths = [Path(self.result.project_path) / lf for lf in ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'COPYING']]
184
- parent_path = Path(self.result.project_path).parent
185
- if parent_path != Path(self.result.project_path):
186
- license_paths += [parent_path / lf for lf in ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'COPYING']]
187
- for license_path in license_paths:
213
+ """Detect license type from LICENSE files.
214
+
215
+ Only sets ``license_file`` (used as a link target) when the file lives
216
+ in the project root, so README links resolve correctly.
217
+ """
218
+ project_root = Path(self.result.project_path)
219
+ in_project = [project_root / lf for lf in ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'COPYING']]
220
+ parent = project_root.parent
221
+ in_parent = [parent / lf for lf in ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'COPYING']] if parent != project_root else []
222
+ for license_path in in_project + in_parent:
188
223
  if license_path.exists():
189
- metadata['license_file'] = license_path.name
224
+ if license_path.parent == project_root:
225
+ metadata['license_file'] = license_path.name
190
226
  if not metadata['license']:
191
227
  try:
192
228
  content = license_path.read_text(encoding='utf-8').lower()
@@ -23,6 +23,50 @@
23
23
  {% if "install" in sections %}
24
24
  ## Installation
25
25
 
26
+ {% if language == 'php' %}
27
+ ### Requirements
28
+
29
+ {% if dependencies and dependencies.runtime_version %}- PHP {{ dependencies.runtime_version }}
30
+ {% else %}- PHP 8.0+
31
+ {% endif %}- [Composer](https://getcomposer.org/)
32
+
33
+ ### From Source
34
+
35
+ ```bash
36
+ git clone {{ repo_url or '<repository-url>' }}
37
+ cd {{ project_name }}
38
+ composer install
39
+ ```
40
+ {% elif language in ('javascript', 'typescript') %}
41
+ ### Requirements
42
+
43
+ {% if dependencies and dependencies.runtime_version %}- Node.js {{ dependencies.runtime_version }}
44
+ {% else %}- Node.js 18+
45
+ {% endif %}
46
+ ### From Source
47
+
48
+ ```bash
49
+ git clone {{ repo_url or '<repository-url>' }}
50
+ cd {{ project_name }}
51
+ {{ dependencies.install_command if dependencies and dependencies.install_command else 'npm install' }}
52
+ ```
53
+ {% elif language == 'rust' %}
54
+ ### From Source
55
+
56
+ ```bash
57
+ git clone {{ repo_url or '<repository-url>' }}
58
+ cd {{ project_name }}
59
+ cargo build --release
60
+ ```
61
+ {% elif language == 'go' %}
62
+ ### From Source
63
+
64
+ ```bash
65
+ git clone {{ repo_url or '<repository-url>' }}
66
+ cd {{ project_name }}
67
+ go build ./...
68
+ ```
69
+ {% else %}
26
70
  ### From PyPI
27
71
 
28
72
  ```bash
@@ -46,10 +90,37 @@ pip install -e .
46
90
  ```
47
91
  {% endif %}
48
92
  {% endif %}
93
+ {% endif %}
49
94
 
50
95
  {% if "quickstart" in sections %}
51
96
  ## Quick Start
52
97
 
98
+ {% if language == 'php' %}
99
+ Serve the project with your preferred PHP runtime (built-in server shown for local development):
100
+
101
+ ```bash
102
+ php -S localhost:8000
103
+ ```
104
+
105
+ Or with Docker Compose if a `docker-compose.yml` is provided:
106
+
107
+ ```bash
108
+ docker compose up
109
+ ```
110
+ {% elif language in ('javascript', 'typescript') %}
111
+ ```bash
112
+ {% if dependencies and dependencies.install_command %}{{ dependencies.install_command }}
113
+ {% endif %}npm start
114
+ ```
115
+ {% elif language == 'rust' %}
116
+ ```bash
117
+ cargo run
118
+ ```
119
+ {% elif language == 'go' %}
120
+ ```bash
121
+ go run .
122
+ ```
123
+ {% else %}
53
124
  ### CLI Usage
54
125
 
55
126
  ```bash
@@ -82,6 +153,7 @@ config = Code2DocsConfig(project_name="mylib", verbose=True)
82
153
  docs = generate_docs("./my-project", config=config)
83
154
  ```
84
155
  {% endif %}
156
+ {% endif %}
85
157
 
86
158
  {% if "generated_output" in sections %}
87
159
  ## Generated Output
@@ -171,8 +243,12 @@ Content outside the markers is preserved when regenerating. Enable this with `sy
171
243
 
172
244
  ```
173
245
  {{ project_name }}/
174
- {% for mod_name, mod in modules.items()|list %}{% if not mod_name.startswith('_') %}{% set parts = mod_name.split('.') %}{{ ' ' * (parts|length - 1) }}├── {{ parts[-1] }}{% if mod.is_package %}/{% endif %}
175
- {% endif %}{% endfor %}
246
+ {% for mod_name, mod in modules.items()|list -%}
247
+ {% if not mod_name.startswith('_') -%}
248
+ {% set parts = mod_name.split('.') -%}
249
+ {{ ' ' * (parts|length - 1) }}├── {{ parts[-1] }}{% if mod.is_package %}/{% endif %}{{ "\n" -}}
250
+ {% endif -%}
251
+ {% endfor -%}
176
252
  ```
177
253
  {% endif %}
178
254
 
@@ -240,7 +316,7 @@ Content outside the markers is preserved when regenerating. Enable this with `sy
240
316
  {% endfor %}
241
317
  {% endif %}
242
318
 
243
- We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
319
+ {% if has_contributing %}We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.{% else %}We welcome contributions! Open an issue or pull request to get started.{% endif %}
244
320
 
245
321
  ### Development Setup
246
322
 
@@ -249,46 +325,51 @@ We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for gu
249
325
  git clone {{ repo_url or '<repository-url>' }}
250
326
  cd {{ project_name }}
251
327
 
328
+ {% if language == 'php' %}
329
+ # Install dependencies
330
+ composer install
331
+
332
+ # Run tests
333
+ vendor/bin/phpunit
334
+ {% elif language in ('javascript', 'typescript') %}
335
+ # Install dependencies
336
+ {{ dependencies.install_command if dependencies and dependencies.install_command else 'npm install' }}
337
+
338
+ # Run tests
339
+ npm test
340
+ {% elif language == 'rust' %}
341
+ # Run tests
342
+ cargo test
343
+ {% elif language == 'go' %}
344
+ # Run tests
345
+ go test ./...
346
+ {% else %}
252
347
  # Install in development mode
253
348
  pip install -e ".[dev]"
254
349
 
255
350
  # Run tests
256
351
  pytest
352
+ {% endif %}
257
353
  ```
258
354
  {% endif %}
259
355
 
260
- {% if "docs_nav" in sections %}
356
+ {% if "docs_nav" in sections and docs_nav_items %}
261
357
  ## Documentation
262
358
 
263
- {% if repo_url %}
264
- - 📖 [Full Documentation]({{ repo_url }}/tree/main/docs) — API reference, module docs, architecture
265
- - 🚀 [Getting Started]({{ repo_url }}/blob/main/docs/getting-started.md) — Quick start guide
266
- - 📚 [API Reference]({{ repo_url }}/blob/main/docs/api.md) — Complete API documentation
267
- - 🔧 [Configuration]({{ repo_url }}/blob/main/docs/configuration.md) — Configuration options
268
- {% else %}
269
- - 📖 [Full Documentation](./docs) — API reference, module docs, architecture
270
- - 🚀 [Getting Started](./docs/getting-started.md) — Quick start guide
271
- - 📚 [API Reference](./docs/api.md) — Complete API documentation
272
- - 🔧 [Configuration](./docs/configuration.md) — Configuration options
273
- {% endif %}
274
- - 💡 [Examples](./examples) — Usage examples and code samples
359
+ {% for item in docs_nav_items %}
360
+ - {{ item.icon }} [{{ item.title }}]({{ item.path }}) — {{ item.description }}
361
+ {% endfor %}
275
362
 
363
+ {% if generated_files %}
276
364
  ### Generated Files
277
365
 
278
366
  | Output | Description | Link |
279
367
  |--------|-------------|------|
280
368
  | `README.md` | Project overview (this file) | — |
281
- | `docs/api.md` | Consolidated API reference | [View](./docs/api.md) |
282
- | `docs/modules.md` | Module reference with metrics | [View](./docs/modules.md) |
283
- | `docs/architecture.md` | Architecture with diagrams | [View](./docs/architecture.md) |
284
- | `docs/dependency-graph.md` | Dependency graphs | [View](./docs/dependency-graph.md) |
285
- | `docs/coverage.md` | Docstring coverage report | [View](./docs/coverage.md) |
286
- | `docs/getting-started.md` | Getting started guide | [View](./docs/getting-started.md) |
287
- | `docs/configuration.md` | Configuration reference | [View](./docs/configuration.md) |
288
- | `docs/api-changelog.md` | API change tracking | [View](./docs/api-changelog.md) |
289
- | `CONTRIBUTING.md` | Contribution guidelines | [View](./CONTRIBUTING.md) |
290
- | `examples/` | Usage examples | [Browse](./examples) |
291
- | `mkdocs.yml` | MkDocs configuration | — |
369
+ {% for file in generated_files %}
370
+ | `{{ file.output }}` | {{ file.description }} | [View]({{ file.link }}) |
371
+ {% endfor %}
372
+ {% endif %}
292
373
  {% endif %}
293
374
 
294
375
  {% if sync_markers %}<!-- code2docs:end -->{% endif %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code2docs
3
- Version: 3.0.29
3
+ Version: 3.0.33
4
4
  Summary: Auto-generate and sync project documentation from source code analysis
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
6
  License-Expression: Apache-2.0
@@ -53,17 +53,17 @@ Dynamic: license-file
53
53
 
54
54
  ## AI Cost Tracking
55
55
 
56
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-3.0.29-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
57
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
56
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-3.0.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
57
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
58
58
 
59
- - 🤖 **LLM usage:** $7.5000 (68 commits)
60
- - 👤 **Human dev:** ~$2384 (23.8h @ $100/h, 30min dedup)
59
+ - 🤖 **LLM usage:** $7.5000 (73 commits)
60
+ - 👤 **Human dev:** ~$2534 (25.3h @ $100/h, 30min dedup)
61
61
 
62
- Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
62
+ Generated on 2026-04-20 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
63
63
 
64
64
  ---
65
65
 
66
- ![version](https://img.shields.io/badge/version-3.0.29-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
66
+ ![version](https://img.shields.io/badge/version-3.0.33-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
67
67
 
68
68
  > Auto-generate and sync project documentation from source code analysis.
69
69
 
@@ -200,7 +200,7 @@ code2docs can update only specific sections of an existing README using markers:
200
200
  ```markdown
201
201
  <!-- code2docs:start --># code2docs
202
202
 
203
- ![version](https://img.shields.io/badge/version-3.0.29-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
203
+ ![version](https://img.shields.io/badge/version-3.0.33-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
204
204
  > **276** functions | **57** classes | **51** files | CC̄ = 3.8
205
205
 
206
206
  > Auto-generated project documentation from source code analysis.
@@ -18,6 +18,7 @@ code2docs/analyzers/__init__.py
18
18
  code2docs/analyzers/dependency_scanner.py
19
19
  code2docs/analyzers/docstring_extractor.py
20
20
  code2docs/analyzers/endpoint_detector.py
21
+ code2docs/analyzers/markdown_validator.py
21
22
  code2docs/analyzers/project_scanner.py
22
23
  code2docs/examples/advanced_usage.py
23
24
  code2docs/examples/quickstart.py
@@ -60,5 +61,6 @@ tests/test_config.py
60
61
  tests/test_formatters.py
61
62
  tests/test_generators.py
62
63
  tests/test_llm_helper.py
64
+ tests/test_markdown_validator.py
63
65
  tests/test_registry.py
64
66
  tests/test_sync.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "code2docs"
7
- version = "3.0.29"
7
+ version = "3.0.33"
8
8
  description = "Auto-generate and sync project documentation from source code analysis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,75 @@
1
+ """Tests for markdown validator."""
2
+ from pathlib import Path
3
+
4
+ from code2docs.analyzers.markdown_validator import (
5
+ validate_markdown_file,
6
+ validate_markdown_tree,
7
+ )
8
+
9
+
10
+ def test_validate_broken_link(tmp_path: Path) -> None:
11
+ md = tmp_path / "README.md"
12
+ md.write_text(
13
+ "# Title\n\n"
14
+ "[missing](./does-not-exist.md)\n"
15
+ "[existing](./present.md)\n",
16
+ encoding="utf-8",
17
+ )
18
+ (tmp_path / "present.md").write_text("# Present", encoding="utf-8")
19
+ issues = validate_markdown_file(md, project_root=tmp_path)
20
+ kinds = {i.kind for i in issues}
21
+ assert "broken_link" in kinds
22
+ assert any("does-not-exist" in i.message for i in issues)
23
+ assert not any("present.md" in i.message for i in issues)
24
+
25
+
26
+ def test_validate_ignores_http_and_anchors(tmp_path: Path) -> None:
27
+ md = tmp_path / "a.md"
28
+ md.write_text(
29
+ "[web](https://example.com)\n"
30
+ "[anchor](#section)\n"
31
+ "[mail](mailto:x@example.com)\n",
32
+ encoding="utf-8",
33
+ )
34
+ assert validate_markdown_file(md, project_root=tmp_path) == []
35
+
36
+
37
+ def test_validate_ignores_links_in_code_fences(tmp_path: Path) -> None:
38
+ md = tmp_path / "a.md"
39
+ md.write_text(
40
+ "```\n[missing](./nope.md)\n```\n",
41
+ encoding="utf-8",
42
+ )
43
+ assert validate_markdown_file(md, project_root=tmp_path) == []
44
+
45
+
46
+ def test_validate_detects_table_shape(tmp_path: Path) -> None:
47
+ md = tmp_path / "a.md"
48
+ md.write_text(
49
+ "| a | b | c |\n"
50
+ "|---|---|---|\n"
51
+ "| 1 | 2 |\n",
52
+ encoding="utf-8",
53
+ )
54
+ issues = validate_markdown_file(md, project_root=tmp_path)
55
+ assert any(i.kind == "table_shape" for i in issues)
56
+
57
+
58
+ def test_validate_detects_duplicate_headings(tmp_path: Path) -> None:
59
+ md = tmp_path / "a.md"
60
+ md.write_text(
61
+ "## Intro\n\n## Intro\n",
62
+ encoding="utf-8",
63
+ )
64
+ issues = validate_markdown_file(md, project_root=tmp_path)
65
+ assert any(i.kind == "duplicate_heading" for i in issues)
66
+
67
+
68
+ def test_validate_tree_skips_ignored_dirs(tmp_path: Path) -> None:
69
+ (tmp_path / "good.md").write_text("# ok\n", encoding="utf-8")
70
+ vendor = tmp_path / "vendor"
71
+ vendor.mkdir()
72
+ (vendor / "bad.md").write_text("[x](./missing.md)\n", encoding="utf-8")
73
+ report = validate_markdown_tree(tmp_path)
74
+ assert report.files_checked == 1
75
+ assert report.ok
File without changes
File without changes
File without changes
File without changes