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.
- package/bin/delimit-setup.js +23 -0
- package/gateway/ai/backends/tools_data.py +830 -0
- package/gateway/ai/backends/tools_design.py +921 -0
- package/gateway/ai/backends/tools_infra.py +866 -0
- package/gateway/ai/backends/tools_real.py +766 -0
- package/gateway/ai/backends/ui_bridge.py +26 -49
- package/gateway/ai/deliberation.py +387 -0
- package/gateway/ai/ledger_manager.py +207 -0
- package/gateway/ai/server.py +630 -216
- package/package.json +1 -1
|
@@ -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
|
+
}
|