worktree-controller 1.30.4__py3-none-any.whl

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.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,677 @@
1
+ """Version 1: explicit project resources, never an ignored-directory deletion heuristic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ import tomllib
12
+ from collections.abc import Iterator
13
+ from contextlib import ExitStack, contextmanager
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .environment import (
19
+ CACHEDIR_SIGNATURE,
20
+ MAX_VENV_ENTRIES,
21
+ _sha256,
22
+ discover_python_project,
23
+ unsafe_ignored_paths,
24
+ )
25
+ from .git_backend import head_paths, ignored_paths, path_is_ignored, tracked_paths
26
+ from .models import ControllerError
27
+
28
+ CONTRACT = "typed-disposable-resource/v1"
29
+ RESOURCE_TYPES = {
30
+ "backend/.venv": "python-venv",
31
+ "frontend/node_modules": "node-modules",
32
+ "frontend/dist": "frontend-build-output",
33
+ }
34
+ MAX_IDENTITY_BYTES = 8 * 1_048_576
35
+ DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
36
+
37
+
38
+ def _inode(metadata: os.stat_result) -> dict[str, int]:
39
+ return {
40
+ "device": metadata.st_dev,
41
+ "inode": metadata.st_ino,
42
+ "file_type": stat.S_IFMT(metadata.st_mode),
43
+ }
44
+
45
+
46
+ def _signature(metadata: os.stat_result) -> tuple[int, ...]:
47
+ return (
48
+ metadata.st_dev,
49
+ metadata.st_ino,
50
+ metadata.st_mode,
51
+ metadata.st_size,
52
+ metadata.st_mtime_ns,
53
+ )
54
+
55
+
56
+ def _link_identity(metadata: os.stat_result) -> list[int]:
57
+ return [
58
+ metadata.st_dev,
59
+ metadata.st_ino,
60
+ metadata.st_mode,
61
+ metadata.st_size,
62
+ metadata.st_mtime_ns,
63
+ metadata.st_ctime_ns,
64
+ metadata.st_uid,
65
+ metadata.st_gid,
66
+ metadata.st_nlink,
67
+ ]
68
+
69
+
70
+ def _race() -> ControllerError:
71
+ return _blocked("resource path or directory entry identity changed", "RESOURCE_PLAN_MISMATCH")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class _Directory:
76
+ parent_fd: int
77
+ name: str
78
+ fd: int
79
+ identity: dict[str, int]
80
+
81
+ def check(self) -> None:
82
+ if (
83
+ _inode(os.fstat(self.fd)) != self.identity
84
+ or _inode(os.stat(self.name, dir_fd=self.parent_fd, follow_symlinks=False))
85
+ != self.identity
86
+ ):
87
+ raise _race()
88
+
89
+
90
+ @contextmanager
91
+ def _open_directory(parent_fd: int, name: str, expected: dict[str, int]) -> Iterator[_Directory]:
92
+ if expected["file_type"] != stat.S_IFDIR:
93
+ raise _race()
94
+ fd = os.open(name, DIRECTORY_FLAGS, dir_fd=parent_fd)
95
+ try:
96
+ directory = _Directory(parent_fd, name, fd, expected)
97
+ directory.check()
98
+ yield directory
99
+ finally:
100
+ os.close(fd)
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class _TreeEntry:
105
+ metadata: os.stat_result
106
+ children: tuple[str, ...] = ()
107
+ link: str | None = None
108
+
109
+
110
+ def _fd_inventory(
111
+ directory_fd: int, *, preserved_fifo: bool = False
112
+ ) -> tuple[dict[str, _TreeEntry], dict[str, Any]]:
113
+ """Capture a no-follow inventory and reproduce the classified tree fingerprint."""
114
+ entries: dict[str, _TreeEntry] = {}
115
+ digest = hashlib.sha256()
116
+ size = 0
117
+
118
+ def visit(parent_fd: int, name: str, relative: str) -> None:
119
+ nonlocal size
120
+ metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
121
+ if len(entries) >= MAX_VENV_ENTRIES:
122
+ raise _blocked("resource inventory exceeded its bound", "RESOURCE_SCAN_LIMIT")
123
+ digest.update(os.fsencode(relative) + b"\0")
124
+ digest.update(":".join(map(str, _signature(metadata))).encode())
125
+ size += metadata.st_size
126
+ entries[relative] = _TreeEntry(metadata)
127
+ if stat.S_ISDIR(metadata.st_mode):
128
+ with _open_directory(parent_fd, name, _inode(metadata)) as directory:
129
+ names = tuple(sorted(os.listdir(directory.fd)))
130
+ entries[relative] = _TreeEntry(metadata, names)
131
+ for child in names:
132
+ visit(directory.fd, child, child if relative == "." else f"{relative}/{child}")
133
+ directory.check()
134
+ if _signature(os.fstat(directory.fd)) != _signature(metadata):
135
+ raise _race()
136
+ elif stat.S_ISLNK(metadata.st_mode):
137
+ link = os.readlink(name, dir_fd=parent_fd)
138
+ digest.update(os.fsencode(link))
139
+ entries[relative] = _TreeEntry(metadata, link=link)
140
+ elif not (
141
+ stat.S_ISREG(metadata.st_mode) or (preserved_fifo and stat.S_ISFIFO(metadata.st_mode))
142
+ ):
143
+ raise _race()
144
+ if _signature(os.stat(name, dir_fd=parent_fd, follow_symlinks=False)) != _signature(
145
+ metadata
146
+ ):
147
+ raise _race()
148
+
149
+ visit(directory_fd, ".", ".")
150
+ return entries, {"tree_sha256": digest.hexdigest(), "entries": len(entries), "bytes": size}
151
+
152
+
153
+ def _unlink_leaf(
154
+ parent: int,
155
+ name: str,
156
+ expected: list[int],
157
+ *,
158
+ link: str | None,
159
+ chain: tuple[_Directory, ...],
160
+ external: dict[str, Any] | None = None,
161
+ ) -> dict[str, Any]:
162
+ from .hardlinks import pinned_content
163
+
164
+ for anchor in chain:
165
+ anchor.check()
166
+ pin = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
167
+ try:
168
+ metadata = os.fstat(pin)
169
+ if _link_identity(metadata) != expected or stat.S_ISDIR(metadata.st_mode):
170
+ raise _race()
171
+ if link is not None and os.readlink(name, dir_fd=parent) != link:
172
+ raise _race()
173
+ if external is not None and pinned_content(pin, expected) != external:
174
+ raise _race()
175
+ for anchor in chain:
176
+ anchor.check()
177
+ if _link_identity(os.stat(name, dir_fd=parent, follow_symlinks=False)) != expected:
178
+ raise _race()
179
+ os.unlink(name, dir_fd=parent)
180
+ after = _link_identity(os.fstat(pin))
181
+ wanted = list(expected)
182
+ wanted[8] -= 1
183
+ wanted[5] = after[5]
184
+ if after != wanted:
185
+ raise _race()
186
+ result = {"identity": after}
187
+ if external is not None:
188
+ content = pinned_content(pin, after)
189
+ if content != external:
190
+ raise _race()
191
+ result["content"] = content
192
+ for anchor in chain:
193
+ anchor.check()
194
+ try:
195
+ os.stat(name, dir_fd=parent, follow_symlinks=False)
196
+ except FileNotFoundError:
197
+ return result
198
+ raise _race()
199
+ finally:
200
+ os.close(pin)
201
+
202
+
203
+ def _delete_fd_tree(
204
+ directory: _Directory,
205
+ relative: str,
206
+ entries: dict[str, _TreeEntry],
207
+ chain: tuple[_Directory, ...],
208
+ link_state: dict[tuple[int, int], list[int]] | None = None,
209
+ external_contents: dict[str, dict[str, Any]] | None = None,
210
+ external_survivors: dict[str, Any] | None = None,
211
+ ) -> dict[str, Any]:
212
+ if external_contents is None:
213
+ external_contents = {}
214
+ if external_survivors is None:
215
+ external_survivors = {}
216
+ if link_state is None:
217
+ link_state = {
218
+ (e.metadata.st_dev, e.metadata.st_ino): _link_identity(e.metadata)
219
+ for e in entries.values()
220
+ if not stat.S_ISDIR(e.metadata.st_mode)
221
+ }
222
+
223
+ def check_chain() -> None:
224
+ for anchor in chain:
225
+ anchor.check()
226
+
227
+ expected = entries[relative]
228
+ check_chain()
229
+ if tuple(sorted(os.listdir(directory.fd))) != expected.children:
230
+ raise _race()
231
+ for name in expected.children:
232
+ child_path = name if relative == "." else f"{relative}/{name}"
233
+ child = entries[child_path]
234
+ metadata = os.stat(name, dir_fd=directory.fd, follow_symlinks=False)
235
+ if _signature(metadata) != _signature(child.metadata):
236
+ raise _race()
237
+ if stat.S_ISDIR(metadata.st_mode):
238
+ with _open_directory(directory.fd, name, _inode(metadata)) as opened:
239
+ _delete_fd_tree(
240
+ opened,
241
+ child_path,
242
+ entries,
243
+ (*chain, opened),
244
+ link_state,
245
+ external_contents,
246
+ external_survivors,
247
+ )
248
+ else:
249
+ key = (metadata.st_dev, metadata.st_ino)
250
+ expected_identity = link_state.get(key)
251
+ if expected_identity is None:
252
+ raise _race()
253
+ external = external_contents.get(child_path)
254
+ proof = _unlink_leaf(
255
+ directory.fd, name, expected_identity, link=child.link, chain=chain, external=external
256
+ )
257
+ link_state[key] = proof["identity"]
258
+ if external is not None:
259
+ external_survivors[f"{key[0]}:{key[1]}"] = proof
260
+ try:
261
+ os.stat(name, dir_fd=directory.fd, follow_symlinks=False)
262
+ except FileNotFoundError:
263
+ pass
264
+ else:
265
+ raise _race()
266
+ check_chain()
267
+ if os.listdir(directory.fd):
268
+ raise _race()
269
+ os.rmdir(directory.name, dir_fd=directory.parent_fd)
270
+ if os.fstat(directory.fd).st_nlink != 0:
271
+ raise _race()
272
+ return external_survivors
273
+
274
+
275
+ def delete_typed_resource(root: Path, relative: str, evidence: dict[str, Any]) -> dict[str, Any]:
276
+ """Delete only through verified directory FDs; paths never authorize a destructive call."""
277
+ if relative not in RESOURCE_TYPES or not root.is_absolute():
278
+ raise _race()
279
+ try:
280
+ with ExitStack() as stack:
281
+ filesystem_fd = os.open("/", DIRECTORY_FLAGS)
282
+ stack.callback(os.close, filesystem_fd)
283
+ parent_fd = filesystem_fd
284
+ chain: list[_Directory] = []
285
+ # Pin even the ancestors of the worktree: opening an absolute root with
286
+ # O_NOFOLLOW alone would still follow a replaced intermediate directory.
287
+ for index, name in enumerate(root.parts[1:]):
288
+ expected = (
289
+ evidence["path_identities"]["."]
290
+ if index == len(root.parts) - 2
291
+ else _inode(os.stat(name, dir_fd=parent_fd, follow_symlinks=False))
292
+ )
293
+ opened = stack.enter_context(_open_directory(parent_fd, name, expected))
294
+ chain.append(opened)
295
+ parent_fd = opened.fd
296
+ for index, name in enumerate(relative.split("/")):
297
+ key = "/".join(relative.split("/")[: index + 1])
298
+ opened = stack.enter_context(
299
+ _open_directory(parent_fd, name, evidence["path_identities"][key])
300
+ )
301
+ chain.append(opened)
302
+ parent_fd = opened.fd
303
+ entries, tree = _fd_inventory(parent_fd)
304
+ if (
305
+ "hardlink_identities" in evidence
306
+ and {
307
+ path: _link_identity(entry.metadata)
308
+ for path, entry in entries.items()
309
+ if not stat.S_ISDIR(entry.metadata.st_mode)
310
+ }
311
+ != evidence["hardlink_identities"]
312
+ ):
313
+ raise _race()
314
+ if any(tree[key] != evidence[key] for key in tree):
315
+ raise _race()
316
+ return _delete_fd_tree(
317
+ chain[-1],
318
+ ".",
319
+ entries,
320
+ tuple(chain),
321
+ external_contents=evidence.get("external_hardlink_contents"),
322
+ )
323
+ except OSError as exc:
324
+ if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP, errno.ESTALE}:
325
+ raise _race() from exc
326
+ raise
327
+
328
+
329
+ def _blocked(message: str, code: str = "RESOURCE_IDENTITY_MISMATCH") -> ControllerError:
330
+ return ControllerError(code, message)
331
+
332
+
333
+ def _regular(path: Path) -> bytes:
334
+ if path.is_symlink() or not path.is_file():
335
+ raise _blocked(f"identity must be a regular file: {path}")
336
+ if path.stat().st_size > MAX_IDENTITY_BYTES:
337
+ raise _blocked(f"identity exceeds scan bound: {path}", "RESOURCE_SCAN_LIMIT")
338
+ return path.read_bytes()
339
+
340
+
341
+ def _project_identity(root: Path, relative: str) -> bytes:
342
+ path = root / relative
343
+ if path.resolve(strict=True) != path:
344
+ raise _blocked(f"project identity escapes its path: {relative}", "RESOURCE_PATH_ESCAPE")
345
+ if tracked_paths(root, relative) != (relative,) or head_paths(root, relative) != (relative,):
346
+ raise _blocked(f"project identity must be committed and indexed: {relative}")
347
+ return _regular(path)
348
+
349
+
350
+ def _json(payload: bytes) -> dict[str, Any]:
351
+ value = json.loads(payload)
352
+ if not isinstance(value, dict):
353
+ raise _blocked("project identity must be a JSON object")
354
+ return value
355
+
356
+
357
+ def _venv_identity(root: Path, resource: Path) -> dict[str, Any]:
358
+ rel_venv = resource.relative_to(root).as_posix()
359
+ project_rel, lock_rel = discover_python_project(root, venv_relative_path=rel_venv)
360
+ project = _project_identity(root, project_rel)
361
+ lock = _project_identity(root, lock_rel)
362
+ if not tomllib.loads(project.decode()).get("project", {}).get("name"):
363
+ raise _blocked("Python project identity is incomplete")
364
+ if not tomllib.loads(lock.decode()).get("version"):
365
+ raise _blocked("Python lock identity is incomplete")
366
+ marker = _regular(resource / "pyvenv.cfg")
367
+ values = {}
368
+ for line in marker.decode().splitlines():
369
+ key, separator, value = line.partition("=")
370
+ if separator:
371
+ values[key.strip().lower()] = value.strip()
372
+ version = values.get("version_info") or values.get("version", "")
373
+ if not re.fullmatch(r"3\.\d+\.\d+(?:\S*)?", version) or not values.get("home"):
374
+ raise _blocked("pyvenv.cfg requires Python version and home identity")
375
+ if values.get("include-system-site-packages") != "false":
376
+ raise _blocked("only isolated Python virtualenvs are supported")
377
+ tag = _regular(resource / "CACHEDIR.TAG")
378
+ if not tag.startswith(CACHEDIR_SIGNATURE):
379
+ raise _blocked("venv requires the standard CACHEDIR.TAG identity")
380
+ allowed = {
381
+ "pyvenv.cfg",
382
+ "CACHEDIR.TAG",
383
+ ".gitignore",
384
+ ".lock",
385
+ "bin",
386
+ "lib",
387
+ "lib64",
388
+ "include",
389
+ }
390
+ if any(path.name not in allowed for path in resource.iterdir()):
391
+ raise _blocked("venv contains unknown top-level content")
392
+ for directory in (resource / "bin", resource / "lib"):
393
+ if directory.is_symlink() or not directory.is_dir():
394
+ raise _blocked("venv bin/lib must be local directories", "RESOURCE_PATH_ESCAPE")
395
+ python = resource / "bin/python"
396
+ target = python.resolve(strict=True)
397
+ major_minor = ".".join(version.split(".")[:2])
398
+ interpreter_names = {"python", "python3", f"python{major_minor}"}
399
+ external_links = []
400
+ if not target.is_relative_to(resource):
401
+ home = Path(values["home"])
402
+ if not home.is_absolute() or target.parent != home.resolve(strict=True):
403
+ raise _blocked("external interpreter differs from pyvenv home", "RESOURCE_PATH_ESCAPE")
404
+ if target.name != f"python{major_minor}":
405
+ raise _blocked(
406
+ "external interpreter name differs from venv version", "RESOURCE_PATH_ESCAPE"
407
+ )
408
+ for name in sorted(interpreter_names):
409
+ link = resource / "bin" / name
410
+ if not os.path.lexists(link):
411
+ continue
412
+ if not link.is_symlink() or link.resolve(strict=True) != target:
413
+ raise _blocked("venv interpreter aliases disagree", "RESOURCE_PATH_ESCAPE")
414
+ raw = Path(os.readlink(link))
415
+ if not (
416
+ (raw.parent == Path(".") and raw.name in interpreter_names)
417
+ or (raw.is_absolute() and raw.parent == home and raw.name in interpreter_names)
418
+ ):
419
+ raise _blocked("nonstandard interpreter link shape", "RESOURCE_PATH_ESCAPE")
420
+ external_links.append(link.relative_to(resource).as_posix())
421
+ if not python.is_file() or not os.access(python, os.X_OK):
422
+ raise _blocked("venv requires an executable Python identity")
423
+ if target.stat().st_size > 128 * 1_048_576:
424
+ raise _blocked("Python executable exceeds scan bound", "RESOURCE_SCAN_LIMIT")
425
+ # Do not execute a checkout-supplied interpreter to establish its identity.
426
+ with python.open("rb") as stream:
427
+ if stream.read(4) != b"\x7fELF":
428
+ raise _blocked("venv Python must be a native Linux executable")
429
+ if not (resource / "lib" / f"python{major_minor}" / "site-packages").is_dir():
430
+ raise _blocked("venv site-packages structure is incomplete")
431
+ return {
432
+ "project_sha256": hashlib.sha256(project).hexdigest(),
433
+ "lock_sha256": hashlib.sha256(lock).hexdigest(),
434
+ "pyvenv_cfg_sha256": hashlib.sha256(marker).hexdigest(),
435
+ "python_version": version,
436
+ "python_target": str(target),
437
+ "python_identity_sha256": _sha256(target),
438
+ "external_interpreter_links": external_links,
439
+ }
440
+
441
+
442
+ def _frontend_identity(root: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]:
443
+ project = _project_identity(root, "frontend/package.json")
444
+ lock = _project_identity(root, "frontend/package-lock.json")
445
+ package, locked = _json(project), _json(lock)
446
+ packages = locked.get("packages")
447
+ if (
448
+ not package.get("name")
449
+ or package.get("workspaces")
450
+ or locked.get("lockfileVersion") not in {2, 3}
451
+ or not isinstance(packages, dict)
452
+ or not isinstance(packages.get(""), dict)
453
+ or packages[""].get("name") != package["name"]
454
+ ):
455
+ raise _blocked("frontend requires an npm project and matching non-workspace lock")
456
+ for kind in ("dependencies", "devDependencies", "optionalDependencies"):
457
+ if package.get(kind, {}) != packages[""].get(kind, {}):
458
+ raise _blocked("frontend dependency declarations differ from lock")
459
+ return (
460
+ package,
461
+ packages,
462
+ {
463
+ "project_sha256": hashlib.sha256(project).hexdigest(),
464
+ "lock_sha256": hashlib.sha256(lock).hexdigest(),
465
+ },
466
+ )
467
+
468
+
469
+ def _node_identity(root: Path, resource: Path) -> dict[str, str]:
470
+ package, packages, evidence = _frontend_identity(root)
471
+ installed_bytes = _regular(resource / ".package-lock.json")
472
+ installed = _json(installed_bytes)
473
+ entries = installed.get("packages")
474
+ if installed.get("name") != package["name"] or not isinstance(entries, dict) or not entries:
475
+ raise _blocked("node_modules lacks its npm installed-tree identity")
476
+ allowed = {".bin", ".package-lock.json", ".vite-temp"}
477
+ for relative, entry in entries.items():
478
+ parts = relative.split("/")
479
+ if (
480
+ len(parts) < 2
481
+ or parts[0] != "node_modules"
482
+ or any(part in {"", ".", ".."} for part in parts)
483
+ or not isinstance(entry, dict)
484
+ or entry.get("link")
485
+ or entry != packages.get(relative)
486
+ ):
487
+ raise _blocked("installed dependency is not bound to the committed npm lock")
488
+ manifest = _json(_regular(root / "frontend" / relative / "package.json"))
489
+ if not entry.get("version") or manifest.get("version") != entry["version"]:
490
+ raise _blocked("installed package version differs from lock")
491
+ allowed.add(parts[1])
492
+ for path in resource.iterdir():
493
+ if path.name not in allowed:
494
+ raise _blocked("node_modules contains unknown top-level content")
495
+ if path.name == ".vite-temp" and (not path.is_dir() or any(path.iterdir())):
496
+ raise _blocked("nonempty Vite temporary state is not dependency identity")
497
+ if path.name.startswith("@"):
498
+ for child in path.iterdir():
499
+ if f"node_modules/{path.name}/{child.name}" not in entries:
500
+ raise _blocked("node_modules contains an unregistered scoped package")
501
+ evidence["installed_lock_sha256"] = hashlib.sha256(installed_bytes).hexdigest()
502
+ return evidence
503
+
504
+
505
+ def _default_output_config(text: str) -> bool:
506
+ """Recognize an empty or server-only exported object, not arbitrary config programs."""
507
+ quoted = r"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"
508
+ plain = re.sub(quoted, "STRING", text)
509
+ plain = re.sub(r"/\*.*?\*/|//[^\n]*", "", plain, flags=re.DOTALL)
510
+ starts = list(re.finditer(r"\breturn\s*(?=\{)", plain))
511
+ if starts:
512
+ if len(starts) != 1 or not re.search(r"export\s+default\s+defineConfig\s*\(", plain):
513
+ return False
514
+ body = plain[starts[0].end() :].strip()
515
+ suffix = r";?\s*\}\s*\)\s*;?"
516
+ else:
517
+ match = re.search(r"export\s+default\s+defineConfig\s*\(\s*(?=\{)", plain)
518
+ if not match:
519
+ return False
520
+ body = plain[match.end() :].strip()
521
+ suffix = r"\)\s*;?"
522
+ # Collapse nested literal objects. Remaining top-level properties must be server only.
523
+ reduced = body
524
+ while re.search(r"\{[^{}]*\}", reduced):
525
+ previous = reduced
526
+ reduced = re.sub(r"\{[^{}]*\}", "OBJECT", reduced)
527
+ if not reduced.startswith("{"):
528
+ outer = previous[: previous.index("}") + 1]
529
+ return bool(
530
+ re.fullmatch(r"\{\s*(?:server\s*:\s*OBJECT\s*,?\s*)?\}", outer)
531
+ and re.fullmatch(suffix, previous[len(outer) :].strip())
532
+ )
533
+ return False
534
+
535
+
536
+ def _dist_identity(root: Path, resource: Path) -> dict[str, str]:
537
+ package, _, evidence = _frontend_identity(root)
538
+ dependencies = {**package.get("dependencies", {}), **package.get("devDependencies", {})}
539
+ if package.get("scripts", {}).get("build") != "vite build" or "vite" not in dependencies:
540
+ raise _blocked("frontend/dist is not a supported declared Vite build output")
541
+ configs = sorted((root / "frontend").glob("vite.config.*"))
542
+ if len(configs) > 1:
543
+ raise _blocked("ambiguous Vite configuration")
544
+ if configs:
545
+ config = _project_identity(root, configs[0].relative_to(root).as_posix())
546
+ text = config.decode()
547
+ # Only the default output with a local, statically recognizable config is supported.
548
+ # Overrides, imports of custom config, spreads and computed properties need review.
549
+ if (
550
+ re.search(r"\b(root|build|outDir|publicDir|extends|require)\b|\.\.\.|\[|`", text)
551
+ or re.search(r"\bimport\b(?!\s*\{[^}]*\}\s*from\s*['\"]vite['\"])", text)
552
+ or re.search(r"\bexport\s+\*|\bprocess\b", text)
553
+ or not _default_output_config(text)
554
+ ):
555
+ raise _blocked("Vite output cannot be established without executing config")
556
+ evidence["build_config_sha256"] = hashlib.sha256(config).hexdigest()
557
+ _project_identity(root, "frontend/index.html")
558
+ _regular(resource / "index.html")
559
+ if not (resource / "assets").is_dir():
560
+ raise _blocked("frontend build output lacks assets")
561
+ if any(path.name not in {"index.html", "assets"} for path in resource.iterdir()):
562
+ raise _blocked("frontend build output contains unknown top-level content")
563
+ if any(
564
+ path.is_dir()
565
+ or path.suffix not in {".js", ".css", ".map", ".svg", ".png", ".jpg", ".woff", ".woff2"}
566
+ for path in (resource / "assets").iterdir()
567
+ ):
568
+ raise _blocked("frontend assets contain unsupported content")
569
+ return evidence
570
+
571
+
572
+ def _tree_identity(
573
+ resource: Path,
574
+ *,
575
+ interpreter_links: tuple[str, ...] = (),
576
+ interpreter_target: str | None = None,
577
+ ) -> dict[str, Any]:
578
+ digest = hashlib.sha256()
579
+ count = size = 0
580
+ pending = [resource]
581
+ while pending:
582
+ path = pending.pop()
583
+ metadata = path.lstat()
584
+ count += 1
585
+ if count > MAX_VENV_ENTRIES:
586
+ raise _blocked("resource inventory exceeded its bound", "RESOURCE_SCAN_LIMIT")
587
+ if metadata.st_uid != os.getuid():
588
+ raise _blocked("resource ownership is not the current user", "RESOURCE_OWNER_MISMATCH")
589
+ if path.name == ".git" or path.is_mount():
590
+ raise _blocked("resource contains Git metadata or a mount")
591
+ relative = path.relative_to(resource).as_posix()
592
+ digest.update(os.fsencode(relative) + b"\0")
593
+ digest.update(":".join(map(str, _signature(metadata))).encode())
594
+ size += metadata.st_size
595
+ if stat.S_ISLNK(metadata.st_mode):
596
+ resolved = path.resolve(strict=True)
597
+ if not resolved.is_relative_to(resource) and not (
598
+ relative in interpreter_links and str(resolved) == interpreter_target
599
+ ):
600
+ raise _blocked(
601
+ f"resource symlink escapes its root: {relative}", "RESOURCE_PATH_ESCAPE"
602
+ )
603
+ digest.update(os.fsencode(os.readlink(path)))
604
+ elif stat.S_ISDIR(metadata.st_mode):
605
+ pending.extend(sorted(path.iterdir(), reverse=True))
606
+ elif not stat.S_ISREG(metadata.st_mode):
607
+ raise _blocked("resource contains a special filesystem object")
608
+ return {"tree_sha256": digest.hexdigest(), "entries": count, "bytes": size}
609
+
610
+
611
+ def resource_evidence(root: Path, relative: str) -> dict[str, Any]:
612
+ if relative not in RESOURCE_TYPES:
613
+ raise _blocked("resource path is outside the typed contract", "UNKNOWN_IGNORED_RESOURCE")
614
+ resource = root / relative
615
+ if resource.is_symlink() or resource.resolve(strict=True) != resource or not resource.is_dir():
616
+ raise _blocked("resource root is not a canonical local directory", "RESOURCE_PATH_ESCAPE")
617
+ if tracked_paths(root, relative) or head_paths(root, relative):
618
+ raise _blocked("resource contains tracked or staged content", "TRACKED_RESOURCE")
619
+ if not path_is_ignored(root, relative):
620
+ raise _blocked("resource is not Git-ignored", "RESOURCE_NOT_IGNORED")
621
+ tree = _tree_identity(resource) if RESOURCE_TYPES[relative] != "python-venv" else None
622
+ identity = {
623
+ "python-venv": _venv_identity,
624
+ "node-modules": _node_identity,
625
+ "frontend-build-output": _dist_identity,
626
+ }[RESOURCE_TYPES[relative]](root, resource)
627
+ tree = tree or _tree_identity(
628
+ resource,
629
+ interpreter_links=tuple(identity.get("external_interpreter_links", ())),
630
+ interpreter_target=identity.get("python_target"),
631
+ )
632
+ path_identities = {
633
+ path.relative_to(root).as_posix(): _inode(path.lstat())
634
+ for path in (root, resource.parent, resource)
635
+ }
636
+ if any(value["file_type"] != stat.S_IFDIR for value in path_identities.values()):
637
+ raise _race()
638
+ return {**tree, **identity, "path_identities": path_identities}
639
+
640
+
641
+ def classify_resources(root: Path) -> tuple[dict[str, Any], ...]:
642
+ """Read-only inventory; legacy release caches receive no typed cleanup action."""
643
+ ignored = ignored_paths(root)
644
+ paths = {path.rstrip("/") for path in ignored}
645
+ paths.update(relative for relative in RESOURCE_TYPES if os.path.lexists(root / relative))
646
+ rows = []
647
+ for relative in sorted(paths):
648
+ if any(relative.startswith(candidate + "/") for candidate in RESOURCE_TYPES):
649
+ continue
650
+ row: dict[str, Any] = {
651
+ "resource_type": RESOURCE_TYPES.get(relative, "unknown"),
652
+ "path": relative,
653
+ "classification": "blocked",
654
+ "cleanup_action": "none",
655
+ "blocker": None,
656
+ }
657
+ if relative in RESOURCE_TYPES:
658
+ try:
659
+ row["evidence"] = resource_evidence(root, relative)
660
+ row.update(classification="disposable", cleanup_action="remove-tree")
661
+ except ControllerError as exc:
662
+ row.update(blocker=exc.code, reason=str(exc))
663
+ except (OSError, ValueError, RuntimeError, TypeError, AttributeError) as exc:
664
+ row.update(
665
+ blocker="RESOURCE_IDENTITY_MISMATCH",
666
+ reason=f"identity inspection failed: {exc}",
667
+ )
668
+ elif not unsafe_ignored_paths((relative,)):
669
+ row.update(
670
+ resource_type="legacy-release-cache", classification="existing-release-policy"
671
+ )
672
+ else:
673
+ row.update(
674
+ blocker="UNKNOWN_IGNORED_RESOURCE", reason="ignored content has no typed contract"
675
+ )
676
+ rows.append(row)
677
+ return tuple(rows)