Coverage for little_loops / cli / docs.py: 10%

121 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""ll-verify-docs and ll-check-links: Documentation verification commands.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import sys 

7from pathlib import Path 

8 

9from little_loops.cli.output import configure_output, print_json, use_color_enabled 

10from little_loops.cli_args import add_json_arg 

11from little_loops.logger import Logger 

12from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

13 

14 

15def main_verify_docs() -> int: 

16 """Entry point for ll-verify-docs command. 

17 

18 Verify that documented counts (commands, agents, skills) match actual file counts. 

19 

20 Returns: 

21 Exit code (0 = all match, 1 = mismatches found) 

22 """ 

23 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-docs", sys.argv[1:]): 

24 from little_loops.doc_counts import ( 

25 fix_counts, 

26 format_result_json, 

27 format_result_markdown, 

28 format_result_text, 

29 verify_documentation, 

30 ) 

31 

32 parser = argparse.ArgumentParser( 

33 prog="ll-verify-docs", 

34 description="Verify documented counts match actual file counts", 

35 formatter_class=argparse.RawDescriptionHelpFormatter, 

36 epilog=""" 

37Examples: 

38 %(prog)s # Check and show results 

39 %(prog)s --json # Output as JSON 

40 %(prog)s --format markdown # Markdown report 

41 %(prog)s --fix # Auto-fix mismatches 

42 

43Exit codes: 

44 0 - All counts match 

45 1 - Mismatches found 

46 2 - Error occurred 

47""", 

48 ) 

49 

50 parser.add_argument( 

51 "-j", 

52 "--json", 

53 action="store_true", 

54 help="Output as JSON", 

55 ) 

56 

57 parser.add_argument( 

58 "-f", 

59 "--format", 

60 choices=["text", "json", "markdown"], 

61 default="text", 

62 help="Output format (default: text)", 

63 ) 

64 

65 parser.add_argument( 

66 "--fix", 

67 action="store_true", 

68 help="Auto-fix count mismatches in documentation files", 

69 ) 

70 

71 parser.add_argument( 

72 "-C", 

73 "--directory", 

74 type=Path, 

75 default=None, 

76 help="Base directory (default: current directory)", 

77 ) 

78 

79 args = parser.parse_args() 

80 

81 configure_output() 

82 logger = Logger(use_color=use_color_enabled()) 

83 

84 # Determine base directory 

85 base_dir = args.directory or Path.cwd() 

86 

87 # Run verification 

88 result = verify_documentation(base_dir) 

89 

90 # Format output 

91 if args.json or args.format == "json": 

92 output = format_result_json(result) 

93 elif args.format == "markdown": 

94 output = format_result_markdown(result) 

95 else: 

96 output = format_result_text(result) 

97 

98 print(output) 

99 

100 # Auto-fix if requested 

101 if args.fix and not result.all_match: 

102 fix_result = fix_counts(base_dir, result) 

103 logger.success( 

104 f"Fixed {fix_result.fixed_count} count(s) in {len(fix_result.files_modified)} file(s)" 

105 ) 

106 

107 # Return exit code based on results 

108 return 0 if result.all_match else 1 

109 

110 

111def main_verify_skill_budget() -> int: 

112 """Entry point for ll-verify-skill-budget command. 

113 

114 Scan skill description tokens and fail if total exceeds the configured budget. 

115 

116 Returns: 

117 Exit code (0 = under budget, 1 = over budget) 

118 """ 

119 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-skill-budget", sys.argv[1:]): 

120 from little_loops.doc_counts import ( 

121 _DEFAULT_BUDGET_TOKENS, 

122 check_skill_budget, 

123 ) 

124 

125 parser = argparse.ArgumentParser( 

126 prog="ll-verify-skill-budget", 

127 description="Verify skill description token footprint stays within listing budget", 

128 formatter_class=argparse.RawDescriptionHelpFormatter, 

129 epilog=""" 

130Examples: 

131 %(prog)s # Check against default 2000-token budget 

132 %(prog)s --threshold 1500 # Custom token threshold 

133 

134Exit codes: 

135 0 - Under budget 

136 1 - Over budget 

137""", 

138 ) 

139 

140 parser.add_argument( 

141 "--threshold", 

142 type=int, 

143 default=None, 

144 metavar="N", 

145 help=f"Token budget threshold (default: {_DEFAULT_BUDGET_TOKENS}; overrides ll-config.json)", 

146 ) 

147 

148 parser.add_argument( 

149 "-C", 

150 "--directory", 

151 type=Path, 

152 default=None, 

153 help="Base directory (default: current directory)", 

154 ) 

155 

156 add_json_arg(parser) 

157 

158 args = parser.parse_args() 

159 

160 configure_output() 

161 logger = Logger(use_color=use_color_enabled()) 

162 

163 base_dir = args.directory or Path.cwd() 

164 

165 # Resolve threshold: CLI arg > config file > default 

166 threshold = args.threshold 

167 if threshold is None: 

168 try: 

169 from little_loops.config import BRConfig 

170 

171 threshold = ( 

172 BRConfig(base_dir) 

173 ._raw_config.get("skill_budget", {}) 

174 .get("threshold_tokens", _DEFAULT_BUDGET_TOKENS) 

175 ) 

176 except Exception: 

177 threshold = _DEFAULT_BUDGET_TOKENS 

178 

179 result = check_skill_budget(base_dir=base_dir, threshold_tokens=threshold) 

180 

181 if args.json: 

182 print_json( 

183 { 

184 "under_budget": result.under_budget, 

185 "total_tokens": result.total_tokens, 

186 "threshold_tokens": result.threshold_tokens, 

187 "skills": [ 

188 { 

189 "name": path.parent.name, 

190 "char_count": len(desc), 

191 "token_estimate": tokens, 

192 } 

193 for path, desc, tokens in result.skill_breakdown 

194 ], 

195 "violations": [ 

196 { 

197 "name": path.parent.name, 

198 "char_count": len(desc), 

199 "token_estimate": tokens, 

200 } 

201 for path, desc, tokens in result.violations 

202 ], 

203 } 

204 ) 

205 return 0 if result.under_budget else 1 

206 

207 # Per-skill breakdown header 

208 print("Skill Description Token Budget Check") 

209 print("=" * 40) 

210 print(f"Threshold: {result.threshold_tokens} tokens") 

211 print() 

212 

213 if result.skill_breakdown: 

214 print(f"{'Tokens':>6} Skill") 

215 print(f"{'------':>6} {'-----'}") 

216 for skill_md, _, tokens in result.skill_breakdown: 

217 marker = " !" if tokens >= 200 else " " 

218 print(f"{tokens:>6}{marker} {skill_md.parent.name}") 

219 print() 

220 

221 if result.under_budget: 

222 logger.success( 

223 f"Total: {result.total_tokens} / {result.threshold_tokens} tokens — under budget" 

224 ) 

225 return 0 

226 else: 

227 logger.error( 

228 f"Total: {result.total_tokens} / {result.threshold_tokens} tokens — OVER BUDGET" 

229 ) 

230 if result.violations: 

231 print("\nTop contributors (≥200 tokens each):") 

232 for skill_md, _, tokens in result.violations: 

233 print(f" {tokens:>6} {skill_md.parent.name}") 

234 return 1 

235 

236 

237def main_verify_skills() -> int: 

238 """Entry point for ll-verify-skills command. 

239 

240 Scan SKILL.md files and fail if any exceed the 500-line limit. 

241 

242 Returns: 

243 Exit code (0 = all within limit, 1 = violations found) 

244 """ 

245 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-skills", sys.argv[1:]): 

246 from little_loops.doc_counts import check_skill_sizes 

247 

248 parser = argparse.ArgumentParser( 

249 prog="ll-verify-skills", 

250 description="Verify no SKILL.md file exceeds the 500-line limit", 

251 formatter_class=argparse.RawDescriptionHelpFormatter, 

252 epilog=""" 

253Examples: 

254 %(prog)s # Check against default 500-line limit 

255 %(prog)s --limit 400 # Custom line limit 

256 %(prog)s --json # Output as JSON 

257 

258Exit codes: 

259 0 - All SKILL.md files within limit 

260 1 - One or more violations found 

261""", 

262 ) 

263 

264 parser.add_argument( 

265 "--limit", 

266 type=int, 

267 default=500, 

268 metavar="N", 

269 help="Maximum allowed lines per SKILL.md (default: 500)", 

270 ) 

271 

272 parser.add_argument( 

273 "-C", 

274 "--directory", 

275 type=Path, 

276 default=None, 

277 help="Base directory (default: current directory)", 

278 ) 

279 

280 add_json_arg(parser) 

281 

282 args = parser.parse_args() 

283 

284 configure_output() 

285 logger = Logger(use_color=use_color_enabled()) 

286 

287 base_dir = args.directory or Path.cwd() 

288 

289 violations = check_skill_sizes(base_dir=base_dir, limit=args.limit) 

290 

291 if args.json: 

292 print_json( 

293 { 

294 "ok": len(violations) == 0, 

295 "limit": args.limit, 

296 "violations": [ 

297 {"name": path.parent.name, "lines": lines} for path, lines in violations 

298 ], 

299 } 

300 ) 

301 return 0 if not violations else 1 

302 

303 if not violations: 

304 logger.success(f"All SKILL.md files within {args.limit}-line limit") 

305 return 0 

306 

307 logger.error(f"{len(violations)} SKILL.md file(s) exceed the {args.limit}-line limit:") 

308 for path, lines in violations: 

309 print(f" {lines:>6} lines {path.parent.name}/SKILL.md") 

310 return 1 

311 

312 

313def main_check_links() -> int: 

314 """Entry point for ll-check-links command. 

315 

316 Check markdown documentation for broken links. 

317 

318 Returns: 

319 Exit code (0 = all links valid, 1 = broken links found, 2 = error) 

320 """ 

321 with cli_event_context(DEFAULT_DB_PATH, "ll-check-links", sys.argv[1:]): 

322 from little_loops.link_checker import ( 

323 check_markdown_links, 

324 format_result_json, 

325 format_result_markdown, 

326 format_result_text, 

327 load_ignore_patterns, 

328 ) 

329 

330 parser = argparse.ArgumentParser( 

331 prog="ll-check-links", 

332 description="Check markdown documentation for broken links", 

333 formatter_class=argparse.RawDescriptionHelpFormatter, 

334 epilog=""" 

335Examples: 

336 %(prog)s # Check all markdown files 

337 %(prog)s --json # Output as JSON 

338 %(prog)s --format markdown # Markdown report 

339 %(prog)s docs/ # Check specific directory 

340 %(prog)s --ignore 'http://localhost.*' # Ignore pattern 

341 

342Exit codes: 

343 0 - All links valid 

344 1 - Broken links found 

345 2 - Error occurred 

346""", 

347 ) 

348 

349 parser.add_argument( 

350 "-j", 

351 "--json", 

352 action="store_true", 

353 help="Output as JSON", 

354 ) 

355 

356 parser.add_argument( 

357 "-f", 

358 "--format", 

359 choices=["text", "json", "markdown"], 

360 default="text", 

361 help="Output format (default: text)", 

362 ) 

363 

364 parser.add_argument( 

365 "-C", 

366 "--directory", 

367 type=Path, 

368 default=None, 

369 help="Base directory (default: current directory)", 

370 ) 

371 

372 parser.add_argument( 

373 "--ignore", 

374 action="append", 

375 default=[], 

376 help="Ignore URL patterns (can be used multiple times)", 

377 ) 

378 

379 parser.add_argument( 

380 "--timeout", 

381 "-t", 

382 type=int, 

383 default=10, 

384 help="Request timeout in seconds (default: 10)", 

385 ) 

386 

387 parser.add_argument( 

388 "-w", 

389 "--workers", 

390 type=int, 

391 default=10, 

392 help="Maximum concurrent HTTP requests (default: 10)", 

393 ) 

394 

395 parser.add_argument( 

396 "-v", 

397 "--verbose", 

398 action="store_true", 

399 help="Show verbose output", 

400 ) 

401 

402 args = parser.parse_args() 

403 

404 configure_output() 

405 

406 # Determine base directory 

407 base_dir = args.directory or Path.cwd() 

408 

409 # Load ignore patterns from config + CLI args 

410 ignore_patterns = load_ignore_patterns(base_dir) 

411 ignore_patterns.extend(args.ignore) 

412 

413 # Run link check 

414 result = check_markdown_links( 

415 base_dir, ignore_patterns, args.timeout, args.verbose, args.workers 

416 ) 

417 

418 # Format output 

419 if args.json or args.format == "json": 

420 output = format_result_json(result) 

421 elif args.format == "markdown": 

422 output = format_result_markdown(result) 

423 else: 

424 output = format_result_text(result) 

425 

426 print(output) 

427 

428 # Return exit code based on results 

429 if result.has_errors: 

430 return 1 

431 return 0