@deftai/directive-content 0.59.0 → 0.61.0

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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,164 +0,0 @@
1
- """Atomic PROJECT-DEFINITION read/write helpers (D14 / #1133).
2
-
3
- Shared by ``scripts/triage_subscribe.py`` and
4
- ``scripts/triage_scope_drift.py`` for the typed-policy mutation
5
- surface introduced by D14 (subscribe / unsubscribe / ignore verbs).
6
-
7
- Mirrors the atomic-write pattern in ``scripts/cache.py::_atomic_write_text``
8
- (tempfile + ``os.replace``) so a crash mid-write leaves the file
9
- untouched. The lifecycle file (``vbrief/PROJECT-DEFINITION.vbrief.json``)
10
- is the only file these helpers touch; the typed policy block lives at
11
- ``data["plan"]["policy"]``.
12
-
13
- Pure stdlib. No third-party dependencies.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import contextlib
19
- import json
20
- import os
21
- import sys
22
- import tempfile
23
- import threading
24
- import time
25
- from collections.abc import Iterator
26
- from pathlib import Path
27
- from typing import Any
28
-
29
- _PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
30
- _mutation_thread_lock = threading.Lock()
31
-
32
-
33
- class ProjectDefinitionIOError(Exception):
34
- """Raised when the PROJECT-DEFINITION file is missing or malformed."""
35
-
36
-
37
- def project_definition_path(project_root: Path) -> Path:
38
- return project_root / _PROJECT_DEFINITION_REL_PATH
39
-
40
-
41
- @contextlib.contextmanager
42
- def project_definition_mutation_lock(project_root: Path) -> Iterator[None]:
43
- """Serialise PROJECT-DEFINITION read-modify-write critical sections.
44
-
45
- The sidecar ``<file>.lock`` is removed on exit -- on the happy path AND
46
- on an exception -- so a clean mutation never leaves
47
- ``vbrief/PROJECT-DEFINITION.vbrief.json.lock`` behind for ``git add -A``
48
- to trap on the next chore commit (#1311). The unlink runs in a
49
- ``finally`` while the in-process ``_mutation_thread_lock`` is still held,
50
- so no concurrent in-process acquirer can race it, and is best-effort: a
51
- cross-process holder (Windows keeps the open file locked) or a benign
52
- POSIX unlink race simply leaves the (gitignored) sidecar in place rather
53
- than raising.
54
- """
55
- path = project_definition_path(project_root)
56
- lock_path = path.parent / (path.name + ".lock")
57
- lock_path.parent.mkdir(parents=True, exist_ok=True)
58
- with _mutation_thread_lock:
59
- try:
60
- with open(lock_path, "a+b") as fh:
61
- if not lock_path.stat().st_size:
62
- fh.write(b"\0")
63
- fh.flush()
64
- fh.seek(0)
65
- if sys.platform == "win32":
66
- import msvcrt
67
-
68
- acquired = False
69
- deadline = time.monotonic() + 30.0
70
- while True:
71
- try:
72
- msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
73
- acquired = True
74
- break
75
- except OSError:
76
- if time.monotonic() > deadline:
77
- raise
78
- time.sleep(0.02)
79
- try:
80
- yield
81
- finally:
82
- if acquired:
83
- fh.seek(0)
84
- with contextlib.suppress(OSError):
85
- msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
86
- else:
87
- import fcntl
88
-
89
- fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
90
- try:
91
- yield
92
- finally:
93
- fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
94
- finally:
95
- # The sidecar handle is closed by the `with open(...)` block above
96
- # BEFORE this unlink (Windows refuses to delete an open file); the
97
- # unlink is held under _mutation_thread_lock so it cannot race an
98
- # in-process re-acquire, and is best-effort across processes
99
- # (#1311 -- do not leave PROJECT-DEFINITION.vbrief.json.lock behind).
100
- with contextlib.suppress(OSError):
101
- lock_path.unlink()
102
-
103
-
104
- def load_project_definition_for_mutation(
105
- project_root: Path,
106
- ) -> tuple[dict[str, Any], Path]:
107
- """Read PROJECT-DEFINITION.vbrief.json and return ``(data, path)``.
108
-
109
- Raises :class:`ProjectDefinitionIOError` if the file is missing or
110
- cannot be parsed as a JSON object. The returned dict is a mutable
111
- deep copy of the on-disk state; callers mutate it and pass it to
112
- :func:`atomic_write_project_definition` to persist.
113
- """
114
- path = project_definition_path(project_root)
115
- if not path.is_file():
116
- raise ProjectDefinitionIOError(
117
- f"PROJECT-DEFINITION not found at {path}; run task triage:welcome / "
118
- "task triage:bootstrap to scaffold one first."
119
- )
120
- try:
121
- raw = path.read_text(encoding="utf-8")
122
- except OSError as exc:
123
- raise ProjectDefinitionIOError(
124
- f"Could not read PROJECT-DEFINITION at {path}: {exc}"
125
- ) from exc
126
- try:
127
- data = json.loads(raw)
128
- except json.JSONDecodeError as exc:
129
- raise ProjectDefinitionIOError(
130
- f"PROJECT-DEFINITION at {path} is not valid JSON: {exc}"
131
- ) from exc
132
- if not isinstance(data, dict):
133
- raise ProjectDefinitionIOError(
134
- f"PROJECT-DEFINITION at {path} top-level value is not a JSON object"
135
- )
136
- return data, path
137
-
138
-
139
- def atomic_write_project_definition(path: Path, data: dict[str, Any]) -> None:
140
- """Atomically write ``data`` to ``path`` as pretty-printed JSON.
141
-
142
- Uses a tempfile + ``os.replace`` so the file is either fully
143
- written or completely unchanged. The parent directory is created
144
- on demand for first-write scenarios (fresh consumer installs).
145
- """
146
- path.parent.mkdir(parents=True, exist_ok=True)
147
- payload = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False)
148
- fd, tmp_name = tempfile.mkstemp(
149
- prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)
150
- )
151
- tmp = Path(tmp_name)
152
- try:
153
- with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
154
- fh.write(payload)
155
- if not payload.endswith("\n"):
156
- fh.write("\n")
157
- fh.flush()
158
- with contextlib.suppress(OSError):
159
- os.fsync(fh.fileno())
160
- os.replace(tmp, path)
161
- except BaseException:
162
- with contextlib.suppress(FileNotFoundError):
163
- tmp.unlink()
164
- raise
@@ -1,209 +0,0 @@
1
- """_relocate_snapshot.py -- snapshot tarball helpers for scripts/relocate.py (#992 PR2).
2
-
3
- Extracted from :mod:`scripts.relocate` to keep the parent under the deft
4
- 1000-line MUST limit. Mirrors the
5
- ``scripts/cache.py`` / ``scripts/_cache_validate.py`` /
6
- ``scripts/_cache_fetch.py`` split pattern.
7
-
8
- Public API:
9
-
10
- - :func:`create_snapshot` -- tar the consumer pre-relocate state.
11
- - :func:`extract_snapshot` -- untar a previously-written snapshot back.
12
- - :func:`latest_snapshot` -- newest snapshot in ``.deft-cache/``.
13
- - :func:`snapshot_path` -- conventional path for the next snapshot.
14
- - :func:`snapshot_dir` -- ``<project-root>/.deft-cache``.
15
- - :func:`utc_timestamp` -- ``YYYYMMDDTHHMMSSZ`` for snapshot filenames.
16
-
17
- The snapshot is gzip-compressed tar with members rooted at
18
- ``project_root`` so ``tar.extractall(project_root, filter='data')``
19
- restores them directly into place.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import shutil
25
- import tarfile
26
- from datetime import UTC, datetime
27
- from pathlib import Path
28
-
29
- CANONICAL_FRAMEWORK_DIR: str = ".deft/core"
30
- LEGACY_FRAMEWORK_DIR: str = "deft"
31
- SNAPSHOT_PREFIX: str = "relocate-snapshot-"
32
-
33
- #: Project-relative paths the snapshot tarball is allowed to capture or
34
- #: leave residue for. The rollback path uses this set to clean any tracked
35
- #: path that was NOT captured in the snapshot (i.e. was created by the
36
- #: relocator post-snapshot) so the rollback restores a byte-equivalent
37
- #: pre-relocate state for tracked files. F3 fix (#1015): without this
38
- #: set the relocator-created ``.gitignore`` was left as residue after a
39
- #: rollback when the pre-relocate project had no ``.gitignore``.
40
- ROLLBACK_TRACKED_PATHS: tuple[str, ...] = (
41
- LEGACY_FRAMEWORK_DIR,
42
- CANONICAL_FRAMEWORK_DIR,
43
- "AGENTS.md",
44
- ".gitignore",
45
- )
46
-
47
-
48
- __all__ = [
49
- "CANONICAL_FRAMEWORK_DIR",
50
- "LEGACY_FRAMEWORK_DIR",
51
- "ROLLBACK_TRACKED_PATHS",
52
- "SNAPSHOT_PREFIX",
53
- "SnapshotError",
54
- "create_snapshot",
55
- "extract_snapshot",
56
- "latest_snapshot",
57
- "snapshot_dir",
58
- "snapshot_path",
59
- "utc_timestamp",
60
- ]
61
-
62
-
63
- class SnapshotError(RuntimeError):
64
- """Snapshot create / extract failure (raised with a descriptive message)."""
65
-
66
- def __init__(self, message: str, *, exit_code: int = 1) -> None:
67
- super().__init__(message)
68
- self.exit_code = exit_code
69
-
70
-
71
- def utc_timestamp() -> str:
72
- """Return ``YYYYMMDDTHHMMSSZ`` suitable for the snapshot filename."""
73
- return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
74
-
75
-
76
- def snapshot_dir(project_root: Path) -> Path:
77
- return project_root / ".deft-cache"
78
-
79
-
80
- def snapshot_path(project_root: Path, *, timestamp: str | None = None) -> Path:
81
- stamp = timestamp or utc_timestamp()
82
- return snapshot_dir(project_root) / f"{SNAPSHOT_PREFIX}{stamp}.tar.gz"
83
-
84
-
85
- def latest_snapshot(project_root: Path) -> Path | None:
86
- sdir = snapshot_dir(project_root)
87
- if not sdir.is_dir():
88
- return None
89
- candidates = sorted(sdir.glob(f"{SNAPSHOT_PREFIX}*.tar.gz"))
90
- return candidates[-1] if candidates else None
91
-
92
-
93
- def create_snapshot(
94
- project_root: Path,
95
- *,
96
- target: Path | None = None,
97
- timestamp: str | None = None,
98
- ) -> Path:
99
- """Tarball the consumer's pre-relocate state into ``.deft-cache/``.
100
-
101
- Captures (when present): every path in :data:`ROLLBACK_TRACKED_PATHS`
102
- -- ``<project-root>/deft/``, ``<project-root>/.deft/core/``,
103
- ``<project-root>/AGENTS.md``, and ``<project-root>/.gitignore``. The
104
- tarball uses paths relative to ``project_root`` so
105
- :func:`extract_snapshot` restores them directly. Tracked paths that
106
- DID NOT exist pre-relocate are intentionally absent from the tarball;
107
- the rollback path uses that absence to recognise relocator-created
108
- residue and remove it (F3 fix #1015).
109
- """
110
- out = target or snapshot_path(project_root, timestamp=timestamp)
111
- out.parent.mkdir(parents=True, exist_ok=True)
112
- members = [project_root / name for name in ROLLBACK_TRACKED_PATHS]
113
- captured = [m for m in members if m.exists()]
114
- with tarfile.open(out, "w:gz") as tar:
115
- for member in captured:
116
- try:
117
- arcname = member.relative_to(project_root).as_posix()
118
- except ValueError:
119
- arcname = member.name
120
- tar.add(str(member), arcname=arcname, recursive=True)
121
- return out
122
-
123
-
124
- def extract_snapshot(
125
- project_root: Path,
126
- *,
127
- snapshot: Path | None = None,
128
- ) -> Path:
129
- """Extract ``snapshot`` (or the most recent) back into ``project_root``.
130
-
131
- Tracked-paths contract (F3 fix, #1015): every path in
132
- :data:`ROLLBACK_TRACKED_PATHS` is reset before extraction --
133
-
134
- - paths captured in the tarball are removed first (so a
135
- partially-relocated tree doesn't carry stale bytes forward) then
136
- re-extracted, and
137
- - paths NOT captured in the tarball are removed unconditionally
138
- because the relocator created them post-snapshot (e.g. a
139
- relocator-created ``.gitignore`` when the pre-relocate project had
140
- none). Without this step, ``git status --porcelain`` post-rollback
141
- would surface ``?? .gitignore`` and break the byte-equivalent
142
- pre-relocate-state contract.
143
-
144
- The ``.deft-cache/`` directory (which hosts the snapshot tarball
145
- itself) is intentionally NOT in the tracked-paths set -- removing it
146
- would break re-rollback against the same snapshot.
147
-
148
- Returns the snapshot path that was actually extracted (handy for the
149
- operator-facing log line).
150
- """
151
- chosen = snapshot or latest_snapshot(project_root)
152
- if chosen is None:
153
- raise SnapshotError(
154
- f"no snapshot found under {snapshot_dir(project_root)} -- "
155
- "rollback requires either --snapshot PATH or a prior wipe-and-reinstall"
156
- )
157
- if not chosen.is_file():
158
- raise SnapshotError(
159
- f"snapshot path {chosen} does not exist or is not a file",
160
- exit_code=2,
161
- )
162
-
163
- # F3 residue fix (#1015): clear EVERY tracked path before extracting
164
- # the tarball. Captured tracked paths are then restored by
165
- # ``extractall``; uncaptured tracked paths (relocator-created
166
- # post-snapshot residue, e.g. a fresh ``.gitignore`` written by
167
- # ``_ensure_gitignore_lines`` when the pre-relocate project had
168
- # none) stay absent so ``git status --porcelain`` is clean.
169
- # Symmetrically removing captured paths up front also guarantees no
170
- # partially-relocated bytes survive into the rolled-back state.
171
- for name in ROLLBACK_TRACKED_PATHS:
172
- target = project_root / name
173
- if target.is_dir() and not target.is_symlink():
174
- shutil.rmtree(target)
175
- elif target.is_file() or target.is_symlink():
176
- target.unlink()
177
-
178
- with tarfile.open(chosen, "r:gz") as tar:
179
- _safe_extract(tar, project_root)
180
- return chosen
181
-
182
-
183
- def _captured_top_level_names(snapshot: Path) -> set[str]:
184
- """Return the set of top-level paths captured in ``snapshot``.
185
-
186
- Used by :func:`extract_snapshot` to distinguish tracked paths that
187
- were captured (and thus will be restored by the tarball extract)
188
- from tracked paths that the relocator created post-snapshot (which
189
- must be removed without restoration to honour the byte-equivalent
190
- pre-relocate-state contract -- F3 fix #1015).
191
- """
192
- with tarfile.open(snapshot, "r:gz") as tar:
193
- names = tar.getnames()
194
- return {n.split("/", 1)[0] for n in names if n}
195
-
196
-
197
- def _safe_extract(tar: tarfile.TarFile, dest: Path) -> None:
198
- """Reject path traversal before extracting (per Python 3.12 best practice)."""
199
- dest_resolved = dest.resolve()
200
- for member in tar.getmembers():
201
- member_target = (dest / member.name).resolve()
202
- try:
203
- member_target.relative_to(dest_resolved)
204
- except ValueError:
205
- raise SnapshotError(
206
- f"snapshot member {member.name!r} would extract outside {dest}",
207
- exit_code=2,
208
- ) from None
209
- tar.extractall(dest, filter="data") # type: ignore[arg-type]