delimit-cli 3.4.0 → 3.5.1

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,766 @@
1
+ """
2
+ Real implementations for Tier 4 tools: test_generate, test_smoke, docs_generate, docs_validate.
3
+
4
+ All tools work WITHOUT external integrations by default.
5
+ They use AST parsing, filesystem scanning, and subprocess invocation.
6
+ """
7
+
8
+ import ast
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ logger = logging.getLogger("delimit.ai.tools_real")
19
+
20
+
21
+ # ═══════════════════════════════════════════════════════════════════════
22
+ # test_generate — Generate test skeletons via AST/regex extraction
23
+ # ═══════════════════════════════════════════════════════════════════════
24
+
25
+ def _extract_python_functions(file_path: Path) -> List[Dict[str, Any]]:
26
+ """Parse a Python file with ast and extract public function/method signatures."""
27
+ try:
28
+ source = file_path.read_text(encoding="utf-8", errors="replace")
29
+ tree = ast.parse(source, filename=str(file_path))
30
+ except (SyntaxError, UnicodeDecodeError):
31
+ return []
32
+
33
+ functions = []
34
+ for node in ast.walk(tree):
35
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
36
+ if node.name.startswith("_"):
37
+ continue
38
+ args = []
39
+ for arg in node.args.args:
40
+ if arg.arg == "self":
41
+ continue
42
+ args.append(arg.arg)
43
+ # Extract docstring if present
44
+ docstring = ast.get_docstring(node) or ""
45
+ # Get return annotation
46
+ ret = ""
47
+ if node.returns and isinstance(node.returns, ast.Constant):
48
+ ret = str(node.returns.value)
49
+ elif node.returns and isinstance(node.returns, ast.Name):
50
+ ret = node.returns.id
51
+
52
+ functions.append({
53
+ "name": node.name,
54
+ "args": args,
55
+ "docstring": docstring[:200],
56
+ "returns": ret,
57
+ "lineno": node.lineno,
58
+ "is_async": isinstance(node, ast.AsyncFunctionDef),
59
+ })
60
+ return functions
61
+
62
+
63
+ def _extract_js_functions(file_path: Path) -> List[Dict[str, Any]]:
64
+ """Extract function names from JS/TS files using regex."""
65
+ try:
66
+ source = file_path.read_text(encoding="utf-8", errors="replace")
67
+ except (OSError, UnicodeDecodeError):
68
+ return []
69
+
70
+ functions = []
71
+ patterns = [
72
+ # function declarations: function myFunc(...)
73
+ r"(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(",
74
+ # arrow / const declarations: const myFunc = (...) =>
75
+ r"(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(?",
76
+ # class methods: myMethod(...) {
77
+ r"^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{",
78
+ ]
79
+ seen = set()
80
+ for pat in patterns:
81
+ for m in re.finditer(pat, source, re.MULTILINE):
82
+ name = m.group(1)
83
+ if name and not name.startswith("_") and name not in seen:
84
+ seen.add(name)
85
+ functions.append({
86
+ "name": name,
87
+ "args": [],
88
+ "docstring": "",
89
+ "returns": "",
90
+ "lineno": source[:m.start()].count("\n") + 1,
91
+ "is_async": "async" in source[max(0, m.start()-20):m.start()],
92
+ })
93
+ return functions
94
+
95
+
96
+ def _find_existing_test_files(project: Path) -> set:
97
+ """Return set of source file stems that already have test files."""
98
+ tested = set()
99
+ for pattern in ["**/test_*.py", "**/*.test.ts", "**/*.test.js", "**/*.spec.ts", "**/*.spec.js", "**/*_test.py"]:
100
+ for tf in project.glob(pattern):
101
+ stem = tf.stem.replace("test_", "").replace(".test", "").replace(".spec", "").replace("_test", "")
102
+ tested.add(stem)
103
+ return tested
104
+
105
+
106
+ def _generate_pytest_skeleton(source_file: Path, functions: List[Dict]) -> str:
107
+ """Generate a pytest test file skeleton."""
108
+ module_name = source_file.stem
109
+ lines = [
110
+ f'"""Auto-generated test skeleton for {source_file.name}."""',
111
+ f"import pytest",
112
+ "",
113
+ ]
114
+ # Try to build a reasonable import
115
+ lines.append(f"# TODO: adjust import path as needed")
116
+ lines.append(f"# from ... import {module_name}")
117
+ lines.append("")
118
+
119
+ for fn in functions:
120
+ args_str = ", ".join(fn["args"])
121
+ test_name = f"test_{fn['name']}"
122
+ lines.append("")
123
+ if fn["docstring"]:
124
+ lines.append(f"# Source docstring: {fn['docstring'][:80]}")
125
+ if fn["is_async"]:
126
+ lines.append(f"@pytest.mark.asyncio")
127
+ lines.append(f"async def {test_name}():")
128
+ else:
129
+ lines.append(f"def {test_name}():")
130
+ lines.append(f' """Test {fn["name"]}({args_str})."""')
131
+ lines.append(f" # TODO: implement test")
132
+ if fn["returns"]:
133
+ lines.append(f" # Expected return type: {fn['returns']}")
134
+ lines.append(f" assert True # placeholder")
135
+ lines.append("")
136
+
137
+ return "\n".join(lines)
138
+
139
+
140
+ def _generate_jest_skeleton(source_file: Path, functions: List[Dict]) -> str:
141
+ """Generate a jest/vitest test file skeleton."""
142
+ module_name = source_file.stem
143
+ lines = [
144
+ f"// Auto-generated test skeleton for {source_file.name}",
145
+ f"// TODO: adjust import path as needed",
146
+ f"// import {{ ... }} from './{module_name}';",
147
+ "",
148
+ f"describe('{module_name}', () => {{",
149
+ ]
150
+ for fn in functions:
151
+ prefix = " "
152
+ lines.append(f"{prefix}test('{fn['name']} should work', {'async ' if fn['is_async'] else ''}() => {{")
153
+ lines.append(f"{prefix} // TODO: implement test")
154
+ lines.append(f"{prefix} expect(true).toBe(true); // placeholder")
155
+ lines.append(f"{prefix}}});")
156
+ lines.append("")
157
+ lines.append("});")
158
+ return "\n".join(lines)
159
+
160
+
161
+ def test_generate(project_path: str, source_files: Optional[List[str]] = None, framework: str = "jest") -> Dict[str, Any]:
162
+ """Generate test skeletons for a project using AST parsing (Python) or regex (JS/TS).
163
+
164
+ Works offline with no external dependencies. Parses source files, extracts
165
+ public function signatures, and generates test file skeletons.
166
+ """
167
+ project = Path(project_path).resolve()
168
+ if not project.is_dir():
169
+ return {"error": "project_not_found", "message": f"Directory not found: {project_path}"}
170
+
171
+ is_python = framework == "pytest"
172
+ existing_tests = _find_existing_test_files(project)
173
+
174
+ # Determine which source files to process
175
+ if source_files:
176
+ candidates = [project / f for f in source_files if (project / f).is_file()]
177
+ else:
178
+ if is_python:
179
+ candidates = sorted(project.rglob("*.py"))
180
+ else:
181
+ candidates = sorted(
182
+ f for ext in ("*.js", "*.ts", "*.jsx", "*.tsx")
183
+ for f in project.rglob(ext)
184
+ )
185
+ # Exclude test files, node_modules, venv, __pycache__
186
+ skip_dirs = {"node_modules", "__pycache__", "venv", ".venv", ".git", "dist", "build", "tests", "test", "__tests__"}
187
+ candidates = [
188
+ f for f in candidates
189
+ if not any(d in f.parts for d in skip_dirs)
190
+ and not f.name.startswith("test_")
191
+ and ".test." not in f.name
192
+ and ".spec." not in f.name
193
+ ]
194
+
195
+ generated = []
196
+ total_functions = 0
197
+ skipped_already_tested = []
198
+
199
+ for src in candidates:
200
+ if src.stem in existing_tests:
201
+ skipped_already_tested.append(str(src.relative_to(project)))
202
+ continue
203
+
204
+ if is_python:
205
+ funcs = _extract_python_functions(src)
206
+ else:
207
+ funcs = _extract_js_functions(src)
208
+
209
+ if not funcs:
210
+ continue
211
+
212
+ total_functions += len(funcs)
213
+
214
+ # Determine output path
215
+ if is_python:
216
+ test_dir = project / "tests"
217
+ test_dir.mkdir(exist_ok=True)
218
+ test_file = test_dir / f"test_{src.stem}.py"
219
+ skeleton = _generate_pytest_skeleton(src, funcs)
220
+ else:
221
+ test_dir = src.parent / "__tests__"
222
+ test_dir.mkdir(exist_ok=True)
223
+ ext = src.suffix
224
+ test_file = test_dir / f"{src.stem}.test{ext}"
225
+ skeleton = _generate_jest_skeleton(src, funcs)
226
+
227
+ test_file.write_text(skeleton, encoding="utf-8")
228
+ generated.append({
229
+ "source": str(src.relative_to(project)),
230
+ "test_file": str(test_file.relative_to(project)),
231
+ "function_count": len(funcs),
232
+ "functions": [f["name"] for f in funcs],
233
+ })
234
+
235
+ return {
236
+ "tool": "test.generate",
237
+ "status": "ok",
238
+ "framework": framework,
239
+ "project_path": str(project),
240
+ "files_generated": len(generated),
241
+ "total_functions": total_functions,
242
+ "generated": generated,
243
+ "skipped_already_tested": skipped_already_tested[:20],
244
+ "timestamp": datetime.now(timezone.utc).isoformat(),
245
+ }
246
+
247
+
248
+ # ═══════════════════════════════════════════════════════════════════════
249
+ # test_smoke — Detect framework and run tests
250
+ # ═══════════════════════════════════════════════════════════════════════
251
+
252
+ def _detect_test_framework(project: Path) -> Optional[Dict[str, str]]:
253
+ """Detect the test framework and return the run command."""
254
+ # Python: pytest
255
+ if (project / "pytest.ini").exists() or (project / "pyproject.toml").exists() or (project / "setup.cfg").exists():
256
+ # Check for pytest in pyproject.toml
257
+ pyproject = project / "pyproject.toml"
258
+ if pyproject.exists():
259
+ content = pyproject.read_text(encoding="utf-8", errors="replace")
260
+ if "pytest" in content or "[tool.pytest" in content:
261
+ return {"framework": "pytest", "cmd": "python -m pytest -q --tb=short"}
262
+ if (project / "pytest.ini").exists():
263
+ return {"framework": "pytest", "cmd": "python -m pytest -q --tb=short"}
264
+ # Check setup.cfg
265
+ setup_cfg = project / "setup.cfg"
266
+ if setup_cfg.exists():
267
+ content = setup_cfg.read_text(encoding="utf-8", errors="replace")
268
+ if "pytest" in content:
269
+ return {"framework": "pytest", "cmd": "python -m pytest -q --tb=short"}
270
+
271
+ # Also detect pytest if there's a tests/ dir with test_*.py files
272
+ tests_dir = project / "tests"
273
+ if tests_dir.is_dir() and any(tests_dir.glob("test_*.py")):
274
+ return {"framework": "pytest", "cmd": "python -m pytest -q --tb=short"}
275
+
276
+ # Node: check package.json
277
+ pkg_json = project / "package.json"
278
+ if pkg_json.exists():
279
+ try:
280
+ pkg = json.loads(pkg_json.read_text(encoding="utf-8"))
281
+ scripts = pkg.get("scripts", {})
282
+ test_script = scripts.get("test", "")
283
+ deps = {**pkg.get("devDependencies", {}), **pkg.get("dependencies", {})}
284
+
285
+ if "vitest" in deps or "vitest" in test_script:
286
+ return {"framework": "vitest", "cmd": "npx vitest run --reporter=json"}
287
+ if "jest" in deps or "jest" in test_script:
288
+ return {"framework": "jest", "cmd": "npx jest --json --silent"}
289
+ if "mocha" in deps or "mocha" in test_script:
290
+ return {"framework": "mocha", "cmd": "npx mocha --reporter json"}
291
+ if test_script and test_script != "echo \"Error: no test specified\" && exit 1":
292
+ return {"framework": "npm_test", "cmd": "npm test"}
293
+ except (json.JSONDecodeError, OSError):
294
+ pass
295
+
296
+ return None
297
+
298
+
299
+ def _parse_pytest_output(stdout: str, stderr: str) -> Dict[str, int]:
300
+ """Parse pytest short summary output."""
301
+ counts = {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}
302
+ # Pytest summary line: "5 passed, 2 failed, 1 error in 1.23s"
303
+ combined = stdout + stderr
304
+ summary_match = re.search(r"([\d]+ passed)?(.*?)([\d]+ failed)?(.*?)([\d]+ error)?(.*?)([\d]+ skipped)?", combined)
305
+ for key in counts:
306
+ m = re.search(rf"(\d+) {key}", combined)
307
+ if m:
308
+ counts[key] = int(m.group(1))
309
+ return counts
310
+
311
+
312
+ def _parse_jest_output(stdout: str) -> Dict[str, int]:
313
+ """Parse jest JSON output."""
314
+ counts = {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}
315
+ try:
316
+ data = json.loads(stdout)
317
+ counts["passed"] = data.get("numPassedTests", 0)
318
+ counts["failed"] = data.get("numFailedTests", 0)
319
+ counts["skipped"] = data.get("numPendingTests", 0)
320
+ except (json.JSONDecodeError, KeyError):
321
+ # Fallback: regex parse
322
+ m = re.search(r"Tests:\s+(\d+) passed", stdout)
323
+ if m:
324
+ counts["passed"] = int(m.group(1))
325
+ m = re.search(r"Tests:\s+(\d+) failed", stdout)
326
+ if m:
327
+ counts["failed"] = int(m.group(1))
328
+ return counts
329
+
330
+
331
+ def test_smoke(project_path: str, test_suite: Optional[str] = None) -> Dict[str, Any]:
332
+ """Detect test framework and run tests. Returns pass/fail/error counts.
333
+
334
+ Works by detecting the test framework from project config files,
335
+ then running the appropriate test command and parsing the output.
336
+ """
337
+ project = Path(project_path).resolve()
338
+ if not project.is_dir():
339
+ return {"error": "project_not_found", "message": f"Directory not found: {project_path}"}
340
+
341
+ detected = _detect_test_framework(project)
342
+ if detected is None:
343
+ return {
344
+ "tool": "test.smoke",
345
+ "status": "no_framework",
346
+ "error": "No test framework detected. Looked for: pytest.ini, pyproject.toml, package.json scripts.test",
347
+ "project_path": str(project),
348
+ }
349
+
350
+ framework = detected["framework"]
351
+ cmd = detected["cmd"]
352
+
353
+ # If a specific suite is requested, append it
354
+ if test_suite:
355
+ cmd = f"{cmd} {test_suite}"
356
+
357
+ # Detect the right Python executable
358
+ if framework == "pytest":
359
+ python_found = False
360
+ # Check for venv
361
+ for venv_dir in ["venv", ".venv", "env"]:
362
+ venv_python = project / venv_dir / "bin" / "python"
363
+ if venv_python.exists():
364
+ cmd = cmd.replace("python", str(venv_python), 1)
365
+ python_found = True
366
+ break
367
+ # Fallback to the current interpreter (handles systems where `python` is missing)
368
+ if not python_found:
369
+ import sys as _sys
370
+ cmd = cmd.replace("python", _sys.executable, 1)
371
+
372
+ try:
373
+ result = subprocess.run(
374
+ cmd,
375
+ shell=True,
376
+ cwd=str(project),
377
+ capture_output=True,
378
+ text=True,
379
+ timeout=120,
380
+ env={**os.environ, "CI": "1", "FORCE_COLOR": "0"},
381
+ )
382
+ except subprocess.TimeoutExpired:
383
+ return {
384
+ "tool": "test.smoke",
385
+ "status": "timeout",
386
+ "error": "Test execution timed out after 120 seconds",
387
+ "framework_detected": framework,
388
+ "project_path": str(project),
389
+ }
390
+ except OSError as e:
391
+ return {
392
+ "tool": "test.smoke",
393
+ "status": "execution_error",
394
+ "error": f"Failed to run test command: {e}",
395
+ "framework_detected": framework,
396
+ "command": cmd,
397
+ }
398
+
399
+ # Parse output based on framework
400
+ if framework == "pytest":
401
+ counts = _parse_pytest_output(result.stdout, result.stderr)
402
+ elif framework in ("jest", "vitest"):
403
+ counts = _parse_jest_output(result.stdout)
404
+ else:
405
+ counts = {"passed": 0, "failed": 0, "errors": 0, "skipped": 0}
406
+ # Try generic parsing
407
+ for key in counts:
408
+ m = re.search(rf"(\d+) {key}", result.stdout + result.stderr)
409
+ if m:
410
+ counts[key] = int(m.group(1))
411
+
412
+ # Truncate output to keep response reasonable
413
+ output = (result.stdout + result.stderr).strip()
414
+ if len(output) > 3000:
415
+ output = output[:1500] + "\n\n... [truncated] ...\n\n" + output[-1500:]
416
+
417
+ return {
418
+ "tool": "test.smoke",
419
+ "status": "ok",
420
+ "exit_code": result.returncode,
421
+ "framework_detected": framework,
422
+ "passed": counts["passed"],
423
+ "failed": counts["failed"],
424
+ "errors": counts["errors"],
425
+ "skipped": counts.get("skipped", 0),
426
+ "all_passed": result.returncode == 0,
427
+ "output": output,
428
+ "command": cmd,
429
+ "project_path": str(project),
430
+ "timestamp": datetime.now(timezone.utc).isoformat(),
431
+ }
432
+
433
+
434
+ # ═══════════════════════════════════════════════════════════════════════
435
+ # docs_generate — Extract docstrings/JSDoc and build markdown reference
436
+ # ═══════════════════════════════════════════════════════════════════════
437
+
438
+ def _extract_python_docs(file_path: Path) -> List[Dict[str, str]]:
439
+ """Extract function signatures and docstrings from a Python file."""
440
+ try:
441
+ source = file_path.read_text(encoding="utf-8", errors="replace")
442
+ tree = ast.parse(source, filename=str(file_path))
443
+ except (SyntaxError, UnicodeDecodeError):
444
+ return []
445
+
446
+ docs = []
447
+ for node in ast.walk(tree):
448
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
449
+ if node.name.startswith("_"):
450
+ continue
451
+ args = []
452
+ for arg in node.args.args:
453
+ if arg.arg == "self":
454
+ continue
455
+ annotation = ""
456
+ if arg.annotation:
457
+ annotation = ast.unparse(arg.annotation) if hasattr(ast, "unparse") else ""
458
+ args.append(f"{arg.arg}: {annotation}" if annotation else arg.arg)
459
+
460
+ ret_annotation = ""
461
+ if node.returns:
462
+ ret_annotation = ast.unparse(node.returns) if hasattr(ast, "unparse") else ""
463
+
464
+ sig = f"{'async ' if isinstance(node, ast.AsyncFunctionDef) else ''}def {node.name}({', '.join(args)})"
465
+ if ret_annotation:
466
+ sig += f" -> {ret_annotation}"
467
+
468
+ docstring = ast.get_docstring(node) or ""
469
+ docs.append({
470
+ "name": node.name,
471
+ "signature": sig,
472
+ "docstring": docstring,
473
+ "lineno": node.lineno,
474
+ })
475
+ elif isinstance(node, ast.ClassDef):
476
+ if node.name.startswith("_"):
477
+ continue
478
+ docstring = ast.get_docstring(node) or ""
479
+ docs.append({
480
+ "name": node.name,
481
+ "signature": f"class {node.name}",
482
+ "docstring": docstring,
483
+ "lineno": node.lineno,
484
+ })
485
+ return docs
486
+
487
+
488
+ def _extract_jsdoc(file_path: Path) -> List[Dict[str, str]]:
489
+ """Extract JSDoc comments and associated function signatures from JS/TS files."""
490
+ try:
491
+ source = file_path.read_text(encoding="utf-8", errors="replace")
492
+ except (OSError, UnicodeDecodeError):
493
+ return []
494
+
495
+ docs = []
496
+ # Match JSDoc blocks followed by function-like declarations
497
+ pattern = r"/\*\*(.*?)\*/\s*(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|(?:const|let|var)\s+(\w+))"
498
+ for m in re.finditer(pattern, source, re.DOTALL):
499
+ jsdoc_body = m.group(1).strip()
500
+ name = m.group(2) or m.group(3)
501
+ if not name:
502
+ continue
503
+ # Clean up JSDoc
504
+ cleaned = re.sub(r"^\s*\*\s?", "", jsdoc_body, flags=re.MULTILINE).strip()
505
+ lineno = source[:m.start()].count("\n") + 1
506
+ docs.append({
507
+ "name": name,
508
+ "signature": name,
509
+ "docstring": cleaned,
510
+ "lineno": lineno,
511
+ })
512
+
513
+ # Also find functions without JSDoc
514
+ func_pattern = r"(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\("
515
+ for m in re.finditer(func_pattern, source):
516
+ name = m.group(1)
517
+ if not any(d["name"] == name for d in docs) and not name.startswith("_"):
518
+ lineno = source[:m.start()].count("\n") + 1
519
+ docs.append({
520
+ "name": name,
521
+ "signature": name,
522
+ "docstring": "",
523
+ "lineno": lineno,
524
+ })
525
+
526
+ return docs
527
+
528
+
529
+ def docs_generate(target: str = ".", options: Optional[Dict] = None) -> Dict[str, Any]:
530
+ """Generate API reference documentation by scanning source files for docstrings/JSDoc.
531
+
532
+ Extracts function signatures and documentation strings, then produces
533
+ a markdown file organized by source file.
534
+ """
535
+ project = Path(target).resolve()
536
+ if not project.is_dir():
537
+ return {"error": "project_not_found", "message": f"Directory not found: {target}"}
538
+
539
+ skip_dirs = {"node_modules", "__pycache__", "venv", ".venv", ".git", "dist", "build"}
540
+
541
+ all_docs = {}
542
+ files_processed = 0
543
+ functions_documented = 0
544
+ functions_undocumented = 0
545
+
546
+ # Scan Python files
547
+ for py_file in sorted(project.rglob("*.py")):
548
+ if any(d in py_file.parts for d in skip_dirs):
549
+ continue
550
+ if py_file.name.startswith("test_") or py_file.name == "conftest.py":
551
+ continue
552
+ docs = _extract_python_docs(py_file)
553
+ if docs:
554
+ files_processed += 1
555
+ rel = str(py_file.relative_to(project))
556
+ all_docs[rel] = docs
557
+ for d in docs:
558
+ if d["docstring"]:
559
+ functions_documented += 1
560
+ else:
561
+ functions_undocumented += 1
562
+
563
+ # Scan JS/TS files
564
+ for ext in ("*.js", "*.ts", "*.jsx", "*.tsx"):
565
+ for js_file in sorted(project.rglob(ext)):
566
+ if any(d in js_file.parts for d in skip_dirs):
567
+ continue
568
+ if ".test." in js_file.name or ".spec." in js_file.name:
569
+ continue
570
+ docs = _extract_jsdoc(js_file)
571
+ if docs:
572
+ files_processed += 1
573
+ rel = str(js_file.relative_to(project))
574
+ all_docs[rel] = docs
575
+ for d in docs:
576
+ if d["docstring"]:
577
+ functions_documented += 1
578
+ else:
579
+ functions_undocumented += 1
580
+
581
+ # Generate markdown
582
+ md_lines = [
583
+ "# API Reference",
584
+ "",
585
+ f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
586
+ "",
587
+ f"Files: {files_processed} | Documented: {functions_documented} | Missing docs: {functions_undocumented}",
588
+ "",
589
+ "---",
590
+ "",
591
+ ]
592
+
593
+ for file_path, docs in sorted(all_docs.items()):
594
+ md_lines.append(f"## `{file_path}`")
595
+ md_lines.append("")
596
+ for d in docs:
597
+ md_lines.append(f"### `{d['signature']}`")
598
+ md_lines.append("")
599
+ if d["docstring"]:
600
+ md_lines.append(d["docstring"])
601
+ else:
602
+ md_lines.append("*No documentation.*")
603
+ md_lines.append("")
604
+ md_lines.append(f"*Line {d['lineno']}*")
605
+ md_lines.append("")
606
+ md_lines.append("---")
607
+ md_lines.append("")
608
+
609
+ output_path = project / "API_REFERENCE.md"
610
+ output_path.write_text("\n".join(md_lines), encoding="utf-8")
611
+
612
+ return {
613
+ "tool": "docs.generate",
614
+ "status": "ok",
615
+ "files_processed": files_processed,
616
+ "functions_documented": functions_documented,
617
+ "functions_undocumented": functions_undocumented,
618
+ "output_path": str(output_path),
619
+ "project_path": str(project),
620
+ "timestamp": datetime.now(timezone.utc).isoformat(),
621
+ }
622
+
623
+
624
+ # ═══════════════════════════════════════════════════════════════════════
625
+ # docs_validate — Check documentation quality and completeness
626
+ # ═══════════════════════════════════════════════════════════════════════
627
+
628
+ def _check_broken_links(md_file: Path, project: Path) -> List[str]:
629
+ """Check for broken internal links in a markdown file."""
630
+ issues = []
631
+ try:
632
+ content = md_file.read_text(encoding="utf-8", errors="replace")
633
+ except OSError:
634
+ return issues
635
+
636
+ # Find markdown links: [text](path)
637
+ for m in re.finditer(r"\[([^\]]*)\]\(([^)]+)\)", content):
638
+ link_text = m.group(1)
639
+ link_target = m.group(2)
640
+
641
+ # Skip external URLs and anchors
642
+ if link_target.startswith(("http://", "https://", "mailto:", "#")):
643
+ continue
644
+
645
+ # Strip anchors from path
646
+ path_part = link_target.split("#")[0]
647
+ if not path_part:
648
+ continue
649
+
650
+ # Resolve relative to the markdown file's directory
651
+ target_path = (md_file.parent / path_part).resolve()
652
+ if not target_path.exists():
653
+ rel_md = str(md_file.relative_to(project))
654
+ issues.append(f"Broken link in {rel_md}: [{link_text}]({link_target})")
655
+
656
+ return issues
657
+
658
+
659
+ def _check_python_docstring_coverage(file_path: Path) -> Dict[str, Any]:
660
+ """Check docstring coverage for public functions in a Python file."""
661
+ try:
662
+ source = file_path.read_text(encoding="utf-8", errors="replace")
663
+ tree = ast.parse(source, filename=str(file_path))
664
+ except (SyntaxError, UnicodeDecodeError):
665
+ return {"total": 0, "documented": 0, "missing": []}
666
+
667
+ total = 0
668
+ documented = 0
669
+ missing = []
670
+
671
+ for node in ast.walk(tree):
672
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
673
+ if node.name.startswith("_"):
674
+ continue
675
+ total += 1
676
+ ds = ast.get_docstring(node)
677
+ if ds:
678
+ documented += 1
679
+ else:
680
+ missing.append(f"{node.name} (line {node.lineno})")
681
+
682
+ return {"total": total, "documented": documented, "missing": missing}
683
+
684
+
685
+ def docs_validate(target: str = ".", options: Optional[Dict] = None) -> Dict[str, Any]:
686
+ """Validate documentation quality: README existence, docstring coverage, broken links.
687
+
688
+ Pure filesystem analysis with no external dependencies.
689
+ """
690
+ project = Path(target).resolve()
691
+ if not project.is_dir():
692
+ return {"error": "project_not_found", "message": f"Directory not found: {target}"}
693
+
694
+ issues = []
695
+ skip_dirs = {"node_modules", "__pycache__", "venv", ".venv", ".git", "dist", "build"}
696
+
697
+ # 1. Check README
698
+ has_readme = False
699
+ for name in ("README.md", "readme.md", "README.rst", "README.txt", "README"):
700
+ if (project / name).exists():
701
+ has_readme = True
702
+ break
703
+ if not has_readme:
704
+ issues.append({"severity": "error", "message": "No README file found in project root"})
705
+
706
+ # 2. Check docstring coverage on Python files
707
+ total_public = 0
708
+ total_documented = 0
709
+ missing_docs = []
710
+
711
+ for py_file in sorted(project.rglob("*.py")):
712
+ if any(d in py_file.parts for d in skip_dirs):
713
+ continue
714
+ if py_file.name.startswith("test_") or py_file.name == "conftest.py":
715
+ continue
716
+
717
+ coverage = _check_python_docstring_coverage(py_file)
718
+ total_public += coverage["total"]
719
+ total_documented += coverage["documented"]
720
+ if coverage["missing"]:
721
+ rel = str(py_file.relative_to(project))
722
+ for m in coverage["missing"]:
723
+ missing_docs.append(f"{rel}: {m}")
724
+
725
+ # 3. Check broken internal links in all markdown files
726
+ broken_links = []
727
+ for md_file in sorted(project.rglob("*.md")):
728
+ if any(d in md_file.parts for d in skip_dirs):
729
+ continue
730
+ broken_links.extend(_check_broken_links(md_file, project))
731
+
732
+ for bl in broken_links:
733
+ issues.append({"severity": "warning", "message": bl})
734
+
735
+ # 4. Check for changelog
736
+ has_changelog = any(
737
+ (project / name).exists()
738
+ for name in ("CHANGELOG.md", "CHANGES.md", "HISTORY.md", "changelog.md")
739
+ )
740
+ if not has_changelog:
741
+ issues.append({"severity": "info", "message": "No CHANGELOG file found"})
742
+
743
+ # Calculate coverage percentage
744
+ coverage_percent = round((total_documented / total_public * 100), 1) if total_public > 0 else 0.0
745
+
746
+ # Add docstring coverage issues
747
+ if coverage_percent < 50:
748
+ issues.append({
749
+ "severity": "warning",
750
+ "message": f"Low docstring coverage: {coverage_percent}% ({total_documented}/{total_public} public functions)"
751
+ })
752
+
753
+ return {
754
+ "tool": "docs.validate",
755
+ "status": "ok",
756
+ "project_path": str(project),
757
+ "has_readme": has_readme,
758
+ "has_changelog": has_changelog,
759
+ "coverage_percent": coverage_percent,
760
+ "total_public_functions": total_public,
761
+ "documented_functions": total_documented,
762
+ "issues": issues,
763
+ "missing_docs": missing_docs[:50], # Cap at 50 to keep response reasonable
764
+ "broken_links": broken_links[:20],
765
+ "timestamp": datetime.now(timezone.utc).isoformat(),
766
+ }