Coverage for little_loops / cli / verify_package_data.py: 30%
108 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""ll-verify-package-data: Package-data escape lint and manifest completeness check.
3Runs two gates to prevent the "asset escapes the wheel" bug class:
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.
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.
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"""
17from __future__ import annotations
19import argparse
20import json
21import re
22import sys
23from dataclasses import dataclass, field
24from pathlib import Path
26from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
28# ---------------------------------------------------------------------------
29# Constants
30# ---------------------------------------------------------------------------
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)
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)
49# ---------------------------------------------------------------------------
50# Data types
51# ---------------------------------------------------------------------------
54@dataclass
55class EscapeViolation:
56 """A single __file__-escape violation."""
58 rel_path: str
59 line_no: int
60 match_text: str
61 parent_count: int
62 depth: int
65@dataclass
66class LintResult:
67 """Result of linting one .py file."""
69 rel_path: str
70 violations: list[EscapeViolation] = field(default_factory=list)
72 @property
73 def has_violations(self) -> bool:
74 return bool(self.violations)
77# ---------------------------------------------------------------------------
78# Core lint logic
79# ---------------------------------------------------------------------------
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
87def _count_parent_steps(match: re.Match[str]) -> int:
88 """Effective number of .parent traversal steps from a regex match.
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")
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()
105 if rel_str in _ALLOWLIST:
106 return LintResult(rel_path=rel_str)
108 depth = _file_depth(py_file, pkg_root)
109 violations: list[EscapeViolation] = []
111 try:
112 lines = py_file.read_text(encoding="utf-8", errors="replace").splitlines()
113 except OSError:
114 return LintResult(rel_path=rel_str)
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 )
133 return LintResult(rel_path=rel_str, violations=violations)
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
146# ---------------------------------------------------------------------------
147# Manifest check
148# ---------------------------------------------------------------------------
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
155 return list_missing_assets()
158# ---------------------------------------------------------------------------
159# Reporting
160# ---------------------------------------------------------------------------
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, ""]
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("")
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("")
191 lines.append("FAILED" if (lint_results or missing_assets) else "PASSED")
192 return "\n".join(lines)
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 )
219# ---------------------------------------------------------------------------
220# Entry point
221# ---------------------------------------------------------------------------
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
235def main_verify_package_data() -> int:
236 """Entry point for ll-verify-package-data.
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
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 )
289 args = parser.parse_args()
291 base_dir = args.directory or Path.cwd()
292 pkg_root = _find_pkg_root(base_dir)
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
301 lint_results: list[LintResult] = []
302 missing_assets: list[tuple[str, ...]] = []
304 if not args.manifest_only:
305 lint_results = run_escape_lint(pkg_root)
307 if not args.lint_only:
308 missing_assets = run_manifest_check()
310 if args.json:
311 print(_format_json_report(lint_results, missing_assets))
312 else:
313 print(_format_text_report(lint_results, missing_assets))
315 return 1 if (lint_results or missing_assets) else 0