Coverage for little_loops / cli / verify_package_data.py: 30%

108 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""ll-verify-package-data: Package-data escape lint and manifest completeness check. 

2 

3Runs two gates to prevent the "asset escapes the wheel" bug class: 

4 

51. **__file__-escape lint** (regression backstop): regex-scans little_loops/ 

6 source for Path(__file__) expressions whose parent-traversal depth exits 

7 the package. Reports violations and exits non-zero. 

8 

92. **Manifest completeness check** (primary gate): verifies every asset 

10 declared in PACKAGE_DATA_ASSETS is accessible via importlib.resources 

11 in the current installation. Exits non-zero when any asset is missing. 

12 

13The lint is syntactic — it catches the pattern but is blind to reads through 

14the shared resolver (covered by gate 2). Both must pass for exit 0. 

15""" 

16 

17from __future__ import annotations 

18 

19import argparse 

20import json 

21import re 

22import sys 

23from dataclasses import dataclass, field 

24from pathlib import Path 

25 

26from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

27 

28# --------------------------------------------------------------------------- 

29# Constants 

30# --------------------------------------------------------------------------- 

31 

32# Files exempt from the escape lint (canonical resolver/plugin-root traversals). 

33# Paths are relative to the little_loops package root, using forward slashes. 

34_ALLOWLIST: frozenset[str] = frozenset( 

35 { 

36 "init/cli.py", # canonical _plugin_root() resolver — intentional traversal 

37 "skill_expander.py", # _find_plugin_root() — same pattern as shared resolver 

38 } 

39) 

40 

41# Match Path(__file__) optionally followed by .resolve(), then either: 

42# - .parents[N] (group 1 = ".parents[N]", group 2 = "N") 

43# - a chain of .parent (group 1 = ".parent...", group 2 = None) 

44_FILE_ESCAPE_RE = re.compile( 

45 r"Path\(__file__\)(?:\.resolve\(\))?" 

46 r"(\.parents\[(\d+)\]|(?:\.parent)+)" 

47) 

48 

49# --------------------------------------------------------------------------- 

50# Data types 

51# --------------------------------------------------------------------------- 

52 

53 

54@dataclass 

55class EscapeViolation: 

56 """A single __file__-escape violation.""" 

57 

58 rel_path: str 

59 line_no: int 

60 match_text: str 

61 parent_count: int 

62 depth: int 

63 

64 

65@dataclass 

66class LintResult: 

67 """Result of linting one .py file.""" 

68 

69 rel_path: str 

70 violations: list[EscapeViolation] = field(default_factory=list) 

71 

72 @property 

73 def has_violations(self) -> bool: 

74 return bool(self.violations) 

75 

76 

77# --------------------------------------------------------------------------- 

78# Core lint logic 

79# --------------------------------------------------------------------------- 

80 

81 

82def _file_depth(py_file: Path, pkg_root: Path) -> int: 

83 """Directory levels between pkg_root and py_file (excludes the filename).""" 

84 return len(py_file.relative_to(pkg_root).parts) - 1 

85 

86 

87def _count_parent_steps(match: re.Match[str]) -> int: 

88 """Effective number of .parent traversal steps from a regex match. 

89 

90 .parents[N] is equivalent to N+1 calls to .parent. 

91 """ 

92 chain = match.group(1) 

93 idx = match.group(2) 

94 if idx is not None: 

95 return int(idx) + 1 

96 return chain.count(".parent") 

97 

98 

99def _lint_file(py_file: Path, pkg_root: Path) -> LintResult: 

100 """Scan one .py file for __file__-escape violations.""" 

101 rel = py_file.relative_to(pkg_root) 

102 # Normalize to forward-slash string for allowlist comparison 

103 rel_str = rel.as_posix() 

104 

105 if rel_str in _ALLOWLIST: 

106 return LintResult(rel_path=rel_str) 

107 

108 depth = _file_depth(py_file, pkg_root) 

109 violations: list[EscapeViolation] = [] 

110 

111 try: 

112 lines = py_file.read_text(encoding="utf-8", errors="replace").splitlines() 

113 except OSError: 

114 return LintResult(rel_path=rel_str) 

115 

116 for line_no, line in enumerate(lines, start=1): 

117 for m in _FILE_ESCAPE_RE.finditer(line): 

118 count = _count_parent_steps(m) 

119 # Escapes the package when traversals exceed depth + 1. 

120 # depth+1 .parent calls land at pkg_root itself (still in-package); 

121 # depth+2 (i.e. count > depth+1) exits to the parent of pkg_root. 

122 if count > depth + 1: 

123 violations.append( 

124 EscapeViolation( 

125 rel_path=rel_str, 

126 line_no=line_no, 

127 match_text=m.group(0), 

128 parent_count=count, 

129 depth=depth, 

130 ) 

131 ) 

132 

133 return LintResult(rel_path=rel_str, violations=violations) 

134 

135 

136def run_escape_lint(pkg_root: Path) -> list[LintResult]: 

137 """Run the __file__-escape lint over all .py files under pkg_root.""" 

138 results: list[LintResult] = [] 

139 for py_file in sorted(pkg_root.rglob("*.py")): 

140 result = _lint_file(py_file, pkg_root) 

141 if result.has_violations: 

142 results.append(result) 

143 return results 

144 

145 

146# --------------------------------------------------------------------------- 

147# Manifest check 

148# --------------------------------------------------------------------------- 

149 

150 

151def run_manifest_check() -> list[tuple[str, ...]]: 

152 """Return asset tuples in PACKAGE_DATA_ASSETS missing from the current install.""" 

153 from little_loops.package_data import list_missing_assets 

154 

155 return list_missing_assets() 

156 

157 

158# --------------------------------------------------------------------------- 

159# Reporting 

160# --------------------------------------------------------------------------- 

161 

162 

163def _format_text_report( 

164 lint_results: list[LintResult], 

165 missing_assets: list[tuple[str, ...]], 

166) -> str: 

167 lines: list[str] = ["Package Data Escape Lint + Manifest Check", "=" * 50, ""] 

168 

169 if lint_results: 

170 lines.append("__file__ Escape Violations:") 

171 for r in lint_results: 

172 for v in r.violations: 

173 lines.append( 

174 f" {v.rel_path}:{v.line_no}: {v.match_text}" 

175 f" (depth={v.depth}, traversals={v.parent_count})" 

176 ) 

177 lines.append("") 

178 else: 

179 lines.append("__file__ escape lint: PASS — no violations") 

180 lines.append("") 

181 

182 if missing_assets: 

183 lines.append("Missing assets (not accessible via importlib.resources):") 

184 for parts in missing_assets: 

185 lines.append(f" little_loops/{'/'.join(parts)}") 

186 lines.append("") 

187 else: 

188 lines.append("Manifest check: PASS — all assets accessible") 

189 lines.append("") 

190 

191 lines.append("FAILED" if (lint_results or missing_assets) else "PASSED") 

192 return "\n".join(lines) 

193 

194 

195def _format_json_report( 

196 lint_results: list[LintResult], 

197 missing_assets: list[tuple[str, ...]], 

198) -> str: 

199 return json.dumps( 

200 { 

201 "escape_violations": [ 

202 { 

203 "file": v.rel_path, 

204 "line": v.line_no, 

205 "match": v.match_text, 

206 "parent_count": v.parent_count, 

207 "depth": v.depth, 

208 } 

209 for r in lint_results 

210 for v in r.violations 

211 ], 

212 "missing_assets": [list(parts) for parts in missing_assets], 

213 "passed": not lint_results and not missing_assets, 

214 }, 

215 indent=2, 

216 ) 

217 

218 

219# --------------------------------------------------------------------------- 

220# Entry point 

221# --------------------------------------------------------------------------- 

222 

223 

224def _find_pkg_root(base_dir: Path) -> Path | None: 

225 """Locate the little_loops/ package directory under base_dir.""" 

226 for candidate in ( 

227 base_dir / "scripts" / "little_loops", 

228 base_dir / "little_loops", 

229 ): 

230 if candidate.is_dir(): 

231 return candidate 

232 return None 

233 

234 

235def main_verify_package_data() -> int: 

236 """Entry point for ll-verify-package-data. 

237 

238 Returns 0 when no __file__ escapes are detected and all manifest assets 

239 are accessible; returns 1 otherwise. 

240 """ 

241 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-package-data", sys.argv[1:]): 

242 parser = argparse.ArgumentParser( 

243 prog="ll-verify-package-data", 

244 description=( 

245 "Lint package code for __file__ escapes that would break non-editable " 

246 "installs, and verify all declared assets are accessible via " 

247 "importlib.resources." 

248 ), 

249 formatter_class=argparse.RawDescriptionHelpFormatter, 

250 epilog="""\ 

251Examples: 

252 %(prog)s # Run lint + manifest check from cwd 

253 %(prog)s -C /path/to/root # Run from a specific project root 

254 %(prog)s --json # Machine-readable JSON output 

255 %(prog)s --lint-only # Run only the __file__-escape lint 

256 %(prog)s --manifest-only # Run only the manifest completeness check 

257 

258Exit codes: 

259 0 - No escape violations; all manifest assets accessible 

260 1 - One or more violations or missing assets 

261""", 

262 ) 

263 parser.add_argument( 

264 "-C", 

265 "--directory", 

266 type=Path, 

267 default=None, 

268 help="Project root containing the little_loops package (default: cwd)", 

269 ) 

270 parser.add_argument( 

271 "--json", 

272 action="store_true", 

273 default=False, 

274 help="Output results as JSON", 

275 ) 

276 parser.add_argument( 

277 "--lint-only", 

278 action="store_true", 

279 default=False, 

280 help="Run only the __file__-escape lint (skip manifest check)", 

281 ) 

282 parser.add_argument( 

283 "--manifest-only", 

284 action="store_true", 

285 default=False, 

286 help="Run only the manifest completeness check (skip lint)", 

287 ) 

288 

289 args = parser.parse_args() 

290 

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

292 pkg_root = _find_pkg_root(base_dir) 

293 

294 if pkg_root is None: 

295 print( 

296 f"ERROR: little_loops package directory not found under {base_dir}", 

297 file=sys.stderr, 

298 ) 

299 return 1 

300 

301 lint_results: list[LintResult] = [] 

302 missing_assets: list[tuple[str, ...]] = [] 

303 

304 if not args.manifest_only: 

305 lint_results = run_escape_lint(pkg_root) 

306 

307 if not args.lint_only: 

308 missing_assets = run_manifest_check() 

309 

310 if args.json: 

311 print(_format_json_report(lint_results, missing_assets)) 

312 else: 

313 print(_format_text_report(lint_results, missing_assets)) 

314 

315 return 1 if (lint_results or missing_assets) else 0