claude-dev-env 8.10.0 → 8.11.1
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.
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
- package/bin/codex-skill-load-block.mjs +93 -0
- package/bin/codex-skill-load-block.test.mjs +134 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +55 -31
- package/bin/install.test.mjs +57 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/blocking/tests/test_repository_exemption.py +7 -0
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/repository_checks/tracked_private_terms.py +15 -1
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/scripts/tests/test_tracked_private_terms.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -1,502 +0,0 @@
|
|
|
1
|
-
"""Safe, optional link planning and debounced compatibility reconciliation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import filecmp
|
|
6
|
-
import os
|
|
7
|
-
import shutil
|
|
8
|
-
import stat
|
|
9
|
-
import threading
|
|
10
|
-
import tempfile
|
|
11
|
-
import time
|
|
12
|
-
from dataclasses import dataclass, field
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import Callable, Iterable, Literal
|
|
15
|
-
|
|
16
|
-
from codex_compat_materializer import (
|
|
17
|
-
MaterializerConfig,
|
|
18
|
-
MaterializerError,
|
|
19
|
-
build_plan,
|
|
20
|
-
publish_plan,
|
|
21
|
-
validate_target_path,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
LinkKind = Literal["symlink", "junction", "copy", "materialize"]
|
|
25
|
-
LinkClassification = Literal["byte_compatible", "source_only", "unsupported"]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class _WatcherConfiguration:
|
|
29
|
-
materialize_link_kind = "materialize"
|
|
30
|
-
copy_link_kind = "copy"
|
|
31
|
-
symlink_link_kind = "symlink"
|
|
32
|
-
junction_link_kind = "junction"
|
|
33
|
-
all_configured_root_names = (".agents", "commands", "hooks", "rules")
|
|
34
|
-
all_unsafe_event_kinds = {"overflow", "ambiguous", "inaccessible", "error", "unknown"}
|
|
35
|
-
reparse_point_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@dataclass(frozen=True)
|
|
39
|
-
class LinkSpec:
|
|
40
|
-
source: str
|
|
41
|
-
target: str
|
|
42
|
-
is_byte_compatible: bool = True
|
|
43
|
-
classification: LinkClassification | None = None
|
|
44
|
-
requested_kind: LinkKind = "symlink"
|
|
45
|
-
should_activate_hook: bool = False
|
|
46
|
-
should_activate_script: bool = False
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclass(frozen=True)
|
|
50
|
-
class LinkDecision:
|
|
51
|
-
source: str
|
|
52
|
-
target: str
|
|
53
|
-
kind: LinkKind
|
|
54
|
-
action: str
|
|
55
|
-
reason: str
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@dataclass
|
|
59
|
-
class LinkPlan:
|
|
60
|
-
decisions: list[LinkDecision] = field(default_factory=list)
|
|
61
|
-
errors: list[str] = field(default_factory=list)
|
|
62
|
-
is_dry_run: bool = True
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@dataclass
|
|
66
|
-
class LinkReport:
|
|
67
|
-
decisions: list[LinkDecision] = field(default_factory=list)
|
|
68
|
-
applied: int = 0
|
|
69
|
-
skipped: int = 0
|
|
70
|
-
errors: list[str] = field(default_factory=list)
|
|
71
|
-
is_reconcile_required: bool = False
|
|
72
|
-
reconcile_reasons: list[str] = field(default_factory=list)
|
|
73
|
-
reconcile_paths: list[str] = field(default_factory=list)
|
|
74
|
-
|
|
75
|
-
@property
|
|
76
|
-
def reconcile_required(self) -> bool:
|
|
77
|
-
return self.is_reconcile_required
|
|
78
|
-
|
|
79
|
-
@reconcile_required.setter
|
|
80
|
-
def reconcile_required(self, is_required: bool) -> None:
|
|
81
|
-
self.is_reconcile_required = is_required
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@dataclass(frozen=True)
|
|
85
|
-
class LinkCapabilities:
|
|
86
|
-
symlink: bool
|
|
87
|
-
junction: bool
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass(frozen=True)
|
|
91
|
-
class WatchEvent:
|
|
92
|
-
source_root: str
|
|
93
|
-
kind: str = "changed"
|
|
94
|
-
is_self_event: bool = False
|
|
95
|
-
path: str = ""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def probe_link_capabilities(
|
|
99
|
-
symlink_probe: Callable[[], bool] | None = None,
|
|
100
|
-
junction_probe: Callable[[], bool] | None = None,
|
|
101
|
-
should_allow_temporary_probe: bool = False,
|
|
102
|
-
) -> LinkCapabilities:
|
|
103
|
-
"""Probe link support while preserving a conservative default.
|
|
104
|
-
|
|
105
|
-
Args:
|
|
106
|
-
symlink_probe: Optional injected symlink capability probe.
|
|
107
|
-
junction_probe: Optional injected junction capability probe.
|
|
108
|
-
should_allow_temporary_probe: Whether injected probes may be executed.
|
|
109
|
-
|
|
110
|
-
Returns:
|
|
111
|
-
The observed capabilities, or unavailable capabilities by default.
|
|
112
|
-
|
|
113
|
-
"""
|
|
114
|
-
if not should_allow_temporary_probe:
|
|
115
|
-
return LinkCapabilities(False, False)
|
|
116
|
-
if symlink_probe is None or junction_probe is None:
|
|
117
|
-
return LinkCapabilities(False, False)
|
|
118
|
-
return LinkCapabilities(
|
|
119
|
-
symlink_probe(),
|
|
120
|
-
junction_probe(),
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _select_kind(spec: LinkSpec, capabilities: LinkCapabilities) -> tuple[LinkKind, str]:
|
|
125
|
-
classification = spec.classification or ("byte_compatible" if spec.is_byte_compatible else "source_only")
|
|
126
|
-
if classification == "source_only":
|
|
127
|
-
return _WatcherConfiguration.materialize_link_kind, "converted asset requires materializer"
|
|
128
|
-
if classification == "unsupported":
|
|
129
|
-
return _WatcherConfiguration.materialize_link_kind, "unsupported asset cannot be linked"
|
|
130
|
-
if spec.should_activate_hook or spec.should_activate_script:
|
|
131
|
-
return _WatcherConfiguration.copy_link_kind, "activation is prohibited"
|
|
132
|
-
if spec.requested_kind == _WatcherConfiguration.junction_link_kind and capabilities.junction:
|
|
133
|
-
return _WatcherConfiguration.junction_link_kind, "requested capability available"
|
|
134
|
-
if spec.requested_kind in (_WatcherConfiguration.junction_link_kind, _WatcherConfiguration.symlink_link_kind) and capabilities.symlink:
|
|
135
|
-
return _WatcherConfiguration.symlink_link_kind, "requested capability unavailable; symlink available"
|
|
136
|
-
return _WatcherConfiguration.copy_link_kind, "link capability unavailable"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _validate_source_path(source_root: Path, source_name: str) -> Path:
|
|
140
|
-
normalized_source = source_name.replace("\\", "/")
|
|
141
|
-
if Path(normalized_source).is_absolute():
|
|
142
|
-
raise ValueError("rooted path is not allowed")
|
|
143
|
-
return validate_target_path(source_root, source_name)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _validate_source_root(source_root: Path) -> Path:
|
|
147
|
-
"""Reject link-like roots and return the canonical source root."""
|
|
148
|
-
expanded_root = source_root.expanduser()
|
|
149
|
-
if expanded_root.is_symlink() or (
|
|
150
|
-
expanded_root.exists() and _is_reparse_point(expanded_root)
|
|
151
|
-
):
|
|
152
|
-
raise ValueError("source_root_path cannot be a symlink or reparse point")
|
|
153
|
-
return expanded_root.resolve()
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _validate_publication_source(source_path: Path) -> None:
|
|
157
|
-
if source_path.is_symlink():
|
|
158
|
-
raise ValueError("publication source cannot be a symlink or reparse point")
|
|
159
|
-
if not source_path.exists():
|
|
160
|
-
raise ValueError("publication source does not exist")
|
|
161
|
-
if _is_reparse_point(source_path):
|
|
162
|
-
raise ValueError("publication source cannot be a symlink or reparse point")
|
|
163
|
-
if not source_path.is_dir():
|
|
164
|
-
return
|
|
165
|
-
for each_path in source_path.rglob("*"):
|
|
166
|
-
if each_path.is_symlink() or _is_reparse_point(each_path):
|
|
167
|
-
raise ValueError("publication source tree contains a symlink or reparse point")
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def build_link_plan(
|
|
171
|
-
all_specs: Iterable[LinkSpec],
|
|
172
|
-
source_root: Path,
|
|
173
|
-
target_root: Path,
|
|
174
|
-
capabilities: LinkCapabilities | None = None,
|
|
175
|
-
should_apply: bool = False,
|
|
176
|
-
should_allow_links: bool = False,
|
|
177
|
-
should_allow_activation: bool = False,
|
|
178
|
-
symlink_probe: Callable[[], bool] | None = None,
|
|
179
|
-
junction_probe: Callable[[], bool] | None = None,
|
|
180
|
-
should_allow_temporary_probe: bool = False,
|
|
181
|
-
) -> LinkPlan:
|
|
182
|
-
"""Build a relative-path-only plan without changing the filesystem.
|
|
183
|
-
|
|
184
|
-
Args:
|
|
185
|
-
all_specs: Link specifications to classify.
|
|
186
|
-
source_root: Source assets are read beneath this source_root_path.
|
|
187
|
-
target_root: Root receiving managed targets.
|
|
188
|
-
capabilities: Optional precomputed link capabilities.
|
|
189
|
-
should_apply: Whether eligible decisions may be authorized.
|
|
190
|
-
should_allow_links: Whether link publication is authorized.
|
|
191
|
-
should_allow_activation: Whether hook or script activation is authorized.
|
|
192
|
-
symlink_probe: Optional injected symlink capability probe.
|
|
193
|
-
junction_probe: Optional injected junction capability probe.
|
|
194
|
-
should_allow_temporary_probe: Whether injected probes may be executed.
|
|
195
|
-
|
|
196
|
-
Returns:
|
|
197
|
-
A plan containing decisions and validation errors.
|
|
198
|
-
|
|
199
|
-
Raises:
|
|
200
|
-
ValueError: If a source path is rooted.
|
|
201
|
-
"""
|
|
202
|
-
plan = LinkPlan(is_dry_run=not should_apply)
|
|
203
|
-
try:
|
|
204
|
-
source_base = _validate_source_root(source_root)
|
|
205
|
-
except ValueError as error:
|
|
206
|
-
plan.errors.append(str(error))
|
|
207
|
-
return plan
|
|
208
|
-
target_base = target_root.expanduser().resolve()
|
|
209
|
-
if source_base == target_base or source_base in target_base.parents or target_base in source_base.parents:
|
|
210
|
-
plan.errors.append("source and target roots overlap")
|
|
211
|
-
return plan
|
|
212
|
-
available = capabilities or probe_link_capabilities(
|
|
213
|
-
symlink_probe=symlink_probe,
|
|
214
|
-
junction_probe=junction_probe,
|
|
215
|
-
should_allow_temporary_probe=should_allow_temporary_probe,
|
|
216
|
-
)
|
|
217
|
-
all_seen_targets: set[str] = set()
|
|
218
|
-
for each_spec in all_specs:
|
|
219
|
-
source_name = each_spec.source
|
|
220
|
-
try:
|
|
221
|
-
_validate_source_path(source_base, source_name)
|
|
222
|
-
except ValueError as error:
|
|
223
|
-
if Path(source_name.replace("\\", "/")).is_absolute():
|
|
224
|
-
raise
|
|
225
|
-
plan.errors.append(str(error))
|
|
226
|
-
continue
|
|
227
|
-
try:
|
|
228
|
-
target_name = each_spec.target
|
|
229
|
-
target_path = validate_target_path(target_base, target_name)
|
|
230
|
-
target_key = target_path.as_posix().casefold()
|
|
231
|
-
if target_key in all_seen_targets or target_path.exists() or target_path.is_symlink():
|
|
232
|
-
raise ValueError("unmanaged target collision")
|
|
233
|
-
all_seen_targets.add(target_key)
|
|
234
|
-
selected_kind, reason = _select_kind(each_spec, available)
|
|
235
|
-
is_linkable = (each_spec.classification or ("byte_compatible" if each_spec.is_byte_compatible else "source_only")) == "byte_compatible"
|
|
236
|
-
can_apply = is_linkable and should_apply and should_allow_links and (should_allow_activation or not (each_spec.should_activate_hook or each_spec.should_activate_script))
|
|
237
|
-
plan.decisions.append(LinkDecision(source_name, target_name, selected_kind, "apply" if can_apply else "report", reason))
|
|
238
|
-
except (OSError, ValueError, MaterializerError) as error:
|
|
239
|
-
plan.errors.append(str(error))
|
|
240
|
-
return plan
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _apply_decision(
|
|
244
|
-
decision: LinkDecision,
|
|
245
|
-
source_root: Path,
|
|
246
|
-
target_root: Path,
|
|
247
|
-
copier: Callable[[Path, Path], None],
|
|
248
|
-
symlink_creator: Callable[[Path, Path, bool], None],
|
|
249
|
-
junction_creator: Callable[[Path, Path], None],
|
|
250
|
-
) -> None:
|
|
251
|
-
source_path = _validate_source_path(source_root, decision.source)
|
|
252
|
-
target_path = validate_target_path(target_root, decision.target)
|
|
253
|
-
if decision.kind == "symlink":
|
|
254
|
-
symlink_creator(source_path, target_path, source_path.is_dir())
|
|
255
|
-
return
|
|
256
|
-
if decision.kind == "junction":
|
|
257
|
-
junction_creator(source_path, target_path)
|
|
258
|
-
return
|
|
259
|
-
copier(source_path, target_path)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _report_unsupported_junction(source_path: Path, target_path: Path) -> None:
|
|
263
|
-
raise RuntimeError("Windows junction creation is unsupported without an injected junction creator")
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def _is_reparse_point(file_path: Path) -> bool:
|
|
267
|
-
file_stat = file_path.stat(follow_symlinks=False)
|
|
268
|
-
return bool(
|
|
269
|
-
getattr(file_stat, "st_file_attributes", 0)
|
|
270
|
-
& _WatcherConfiguration.reparse_point_attribute
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _validate_copy_source(source_path: Path) -> None:
|
|
275
|
-
if source_path.is_symlink() or _is_reparse_point(source_path):
|
|
276
|
-
raise ValueError("copy source cannot be a symlink or reparse point")
|
|
277
|
-
if source_path.is_file():
|
|
278
|
-
return
|
|
279
|
-
if not source_path.is_dir():
|
|
280
|
-
raise ValueError("copy source must be a regular file or directory")
|
|
281
|
-
for each_path in source_path.rglob("*"):
|
|
282
|
-
if each_path.is_symlink() or _is_reparse_point(each_path):
|
|
283
|
-
raise ValueError("copy source tree contains a symlink or reparse point")
|
|
284
|
-
if not each_path.is_file() and not each_path.is_dir():
|
|
285
|
-
raise ValueError("copy source tree contains a non-regular entry")
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def _verify_copy(source_path: Path, target_path: Path) -> bool:
|
|
289
|
-
if source_path.is_file():
|
|
290
|
-
return target_path.is_file() and filecmp.cmp(source_path, target_path, shallow=False)
|
|
291
|
-
if not target_path.is_dir():
|
|
292
|
-
return False
|
|
293
|
-
all_source_files = {each_path.relative_to(source_path) for each_path in source_path.rglob("*") if each_path.is_file()}
|
|
294
|
-
all_target_files = {each_path.relative_to(target_path) for each_path in target_path.rglob("*") if each_path.is_file()}
|
|
295
|
-
return all_source_files == all_target_files and all(
|
|
296
|
-
filecmp.cmp(source_path / each_file, target_path / each_file, shallow=False)
|
|
297
|
-
for each_file in all_source_files
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def _copy_asset(source_path: Path, target_path: Path) -> None:
|
|
302
|
-
_validate_copy_source(source_path)
|
|
303
|
-
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
-
if target_path.exists() or target_path.is_symlink():
|
|
305
|
-
raise FileExistsError(target_path)
|
|
306
|
-
with tempfile.TemporaryDirectory(dir=target_path.parent) as staging_directory:
|
|
307
|
-
staging_path = Path(staging_directory) / target_path.name
|
|
308
|
-
if source_path.is_file():
|
|
309
|
-
shutil.copy2(source_path, staging_path)
|
|
310
|
-
else:
|
|
311
|
-
shutil.copytree(source_path, staging_path, symlinks=False)
|
|
312
|
-
if not _verify_copy(source_path, staging_path):
|
|
313
|
-
raise OSError("copied asset failed content verification")
|
|
314
|
-
os.rename(staging_path, target_path)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def publish_link_plan(
|
|
318
|
-
plan: LinkPlan,
|
|
319
|
-
source_root: Path,
|
|
320
|
-
target_root: Path,
|
|
321
|
-
copier: Callable[[Path, Path], None] | None = None,
|
|
322
|
-
symlink_creator: Callable[[Path, Path, bool], None] | None = None,
|
|
323
|
-
junction_creator: Callable[[Path, Path], None] | None = None,
|
|
324
|
-
materializer: Callable[[], object] | None = None,
|
|
325
|
-
) -> LinkReport:
|
|
326
|
-
"""Publish explicitly authorized decisions and report failures safely.
|
|
327
|
-
|
|
328
|
-
Args:
|
|
329
|
-
plan: Decisions produced by ``build_link_plan``.
|
|
330
|
-
source_root: Source assets are read beneath this source_root_path.
|
|
331
|
-
target_root: Root receiving managed targets.
|
|
332
|
-
copier: Optional injected copy operation.
|
|
333
|
-
symlink_creator: Optional injected symlink operation.
|
|
334
|
-
junction_creator: Optional injected junction operation.
|
|
335
|
-
materializer: Optional callback for materialization decisions.
|
|
336
|
-
|
|
337
|
-
Returns:
|
|
338
|
-
A report of applied, skipped, and failed decisions.
|
|
339
|
-
|
|
340
|
-
Raises:
|
|
341
|
-
ValueError: If a decision contains an unsafe rooted path.
|
|
342
|
-
"""
|
|
343
|
-
report = LinkReport(list(plan.decisions), errors=list(plan.errors), is_reconcile_required=bool(plan.errors))
|
|
344
|
-
try:
|
|
345
|
-
source_base = _validate_source_root(source_root)
|
|
346
|
-
except ValueError as error:
|
|
347
|
-
report.errors.append(str(error))
|
|
348
|
-
report.is_reconcile_required = True
|
|
349
|
-
return report
|
|
350
|
-
copy_asset = copier or _copy_asset
|
|
351
|
-
create_symlink = symlink_creator or os.symlink
|
|
352
|
-
create_junction = junction_creator or _report_unsupported_junction
|
|
353
|
-
for each_decision in plan.decisions:
|
|
354
|
-
if each_decision.action != "apply":
|
|
355
|
-
report.skipped += 1
|
|
356
|
-
continue
|
|
357
|
-
try:
|
|
358
|
-
source_path = _validate_source_path(source_base, each_decision.source)
|
|
359
|
-
_validate_publication_source(source_path)
|
|
360
|
-
if each_decision.kind == _WatcherConfiguration.materialize_link_kind:
|
|
361
|
-
if materializer is None:
|
|
362
|
-
raise RuntimeError("materializer callback is required")
|
|
363
|
-
materializer()
|
|
364
|
-
else:
|
|
365
|
-
_apply_decision(each_decision, source_base, target_root, copy_asset, create_symlink, create_junction)
|
|
366
|
-
report.applied += 1
|
|
367
|
-
except (OSError, RuntimeError, ValueError) as error:
|
|
368
|
-
report.errors.append(str(error))
|
|
369
|
-
report.is_reconcile_required = True
|
|
370
|
-
report.reconcile_reasons.append(type(error).__name__)
|
|
371
|
-
report.reconcile_paths.append(each_decision.target)
|
|
372
|
-
return report
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
class DebouncedReconciler:
|
|
376
|
-
"""Coalesce scoped watcher events and serialize reconciliation callbacks.
|
|
377
|
-
|
|
378
|
-
The reconciler accepts events from five configured source roots, suppresses
|
|
379
|
-
self-generated events first, and adds sorted unsafe-event details to reports.
|
|
380
|
-
"""
|
|
381
|
-
|
|
382
|
-
default_debounce_seconds = 0.25
|
|
383
|
-
|
|
384
|
-
def __init__(
|
|
385
|
-
self,
|
|
386
|
-
reconcile: Callable[[], object],
|
|
387
|
-
debounce_seconds: float | None = None,
|
|
388
|
-
all_configured_roots: Iterable[str] | None = None,
|
|
389
|
-
) -> None:
|
|
390
|
-
self._reconcile = reconcile
|
|
391
|
-
self._debounce_seconds = (
|
|
392
|
-
debounce_seconds
|
|
393
|
-
if debounce_seconds is not None
|
|
394
|
-
else self.default_debounce_seconds
|
|
395
|
-
)
|
|
396
|
-
self._lock = threading.Lock()
|
|
397
|
-
self._pending = False
|
|
398
|
-
self._last_event = 0.0
|
|
399
|
-
self._running = False
|
|
400
|
-
self._stopped = False
|
|
401
|
-
self._reconcile_reasons: list[str] = []
|
|
402
|
-
self._reconcile_paths: list[str] = []
|
|
403
|
-
self._configured_roots = self._normalize_configured_roots(
|
|
404
|
-
all_configured_roots or _WatcherConfiguration.all_configured_root_names
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
@staticmethod
|
|
408
|
-
def _normalize_configured_roots(all_configured_roots: Iterable[str]) -> frozenset[str]:
|
|
409
|
-
normalized_roots: set[str] = set()
|
|
410
|
-
for each_root in all_configured_roots:
|
|
411
|
-
normalized_roots.add(each_root.casefold())
|
|
412
|
-
normalized_roots.add(Path(each_root).expanduser().resolve().as_posix().casefold())
|
|
413
|
-
return frozenset(normalized_roots)
|
|
414
|
-
|
|
415
|
-
def start(self) -> None:
|
|
416
|
-
with self._lock:
|
|
417
|
-
self._stopped = False
|
|
418
|
-
|
|
419
|
-
def stop(self) -> None:
|
|
420
|
-
with self._lock:
|
|
421
|
-
self._stopped = True
|
|
422
|
-
self._pending = False
|
|
423
|
-
self._reconcile_reasons.clear()
|
|
424
|
-
self._reconcile_paths.clear()
|
|
425
|
-
|
|
426
|
-
def notify(self, event: WatchEvent) -> None:
|
|
427
|
-
"""Record a scoped filesystem event for later reconciliation.
|
|
428
|
-
|
|
429
|
-
Args:
|
|
430
|
-
event: Filesystem event to coalesce or classify.
|
|
431
|
-
"""
|
|
432
|
-
if event.is_self_event:
|
|
433
|
-
return
|
|
434
|
-
source_root_name = event.source_root.casefold()
|
|
435
|
-
source_root_path = Path(event.source_root).expanduser().resolve().as_posix().casefold()
|
|
436
|
-
if source_root_name not in self._configured_roots and source_root_path not in self._configured_roots:
|
|
437
|
-
return
|
|
438
|
-
if event.kind in _WatcherConfiguration.all_unsafe_event_kinds:
|
|
439
|
-
with self._lock:
|
|
440
|
-
if not self._stopped:
|
|
441
|
-
self._pending = True
|
|
442
|
-
self._reconcile_reasons.append(event.kind)
|
|
443
|
-
self._reconcile_paths.append(event.path or event.source_root)
|
|
444
|
-
self._last_event = time.monotonic()
|
|
445
|
-
return
|
|
446
|
-
with self._lock:
|
|
447
|
-
if not self._stopped:
|
|
448
|
-
self._pending = True
|
|
449
|
-
self._last_event = time.monotonic()
|
|
450
|
-
|
|
451
|
-
def flush(self, should_force: bool = False) -> object | None:
|
|
452
|
-
"""Run the pending reconciliation when debounce and state allow it.
|
|
453
|
-
|
|
454
|
-
Args:
|
|
455
|
-
should_force: Whether to bypass the debounce interval.
|
|
456
|
-
|
|
457
|
-
Returns:
|
|
458
|
-
The reconciliation report, or ``None`` when no run is eligible.
|
|
459
|
-
"""
|
|
460
|
-
with self._lock:
|
|
461
|
-
if self._stopped or not self._pending or self._running or (not should_force and time.monotonic() - self._last_event < self._debounce_seconds):
|
|
462
|
-
return None
|
|
463
|
-
self._pending = False
|
|
464
|
-
self._running = True
|
|
465
|
-
try:
|
|
466
|
-
reconciliation_report = self._reconcile()
|
|
467
|
-
return self._report_with_event_details(reconciliation_report)
|
|
468
|
-
except (OSError, RuntimeError, ValueError) as error:
|
|
469
|
-
return self._report_with_event_details({"reconcile_required": True, "errors": [str(error)]})
|
|
470
|
-
finally:
|
|
471
|
-
with self._lock:
|
|
472
|
-
self._running = False
|
|
473
|
-
|
|
474
|
-
def _report_with_event_details(self, reconciliation_report: object) -> object:
|
|
475
|
-
with self._lock:
|
|
476
|
-
event_reasons = sorted(self._reconcile_reasons)
|
|
477
|
-
event_paths = sorted(self._reconcile_paths)
|
|
478
|
-
self._reconcile_reasons.clear()
|
|
479
|
-
self._reconcile_paths.clear()
|
|
480
|
-
if isinstance(reconciliation_report, dict):
|
|
481
|
-
reconciliation_report = dict(reconciliation_report)
|
|
482
|
-
if event_reasons:
|
|
483
|
-
reconciliation_report["reconcile_required"] = True
|
|
484
|
-
reconciliation_report["reconcile_reasons"] = event_reasons
|
|
485
|
-
reconciliation_report["reconcile_paths"] = event_paths
|
|
486
|
-
return reconciliation_report
|
|
487
|
-
if not event_reasons:
|
|
488
|
-
return reconciliation_report
|
|
489
|
-
return {"reconciliation": reconciliation_report, "reconcile_required": True, "reconcile_reasons": event_reasons, "reconcile_paths": event_paths}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def reconcile_materialized_agents(materializer_config: MaterializerConfig) -> object:
|
|
493
|
-
"""Run the materializer's complete deterministic public reconciliation.
|
|
494
|
-
|
|
495
|
-
Args:
|
|
496
|
-
materializer_config: Configuration for the materialized compatibility files.
|
|
497
|
-
|
|
498
|
-
Returns:
|
|
499
|
-
The materializer publication report.
|
|
500
|
-
"""
|
|
501
|
-
planned_files, publication_report = build_plan(materializer_config)
|
|
502
|
-
return publish_plan(materializer_config, planned_files, publication_report)
|