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

2

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

4

5 1. **__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

9 2. **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

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

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

15"""

16

17 from __future__ import annotations

18

19 import argparse

20 import json

21 import re

22 import sys

23 from dataclasses import dataclass, field

24 from pathlib import Path

25

26 from 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

55 class 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

66 class 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

82 def _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

87 def _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

99 def _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

136 def 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

151 def 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

163 def _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

195 def _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

224 def _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

235 def 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="""\

251 Examples:

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

258 Exit 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