getcodexy 1.13.0__tar.gz → 1.13.2__tar.gz
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.
- {getcodexy-1.13.0 → getcodexy-1.13.2}/PKG-INFO +1 -1
- {getcodexy-1.13.0 → getcodexy-1.13.2}/build_backend.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/pyproject.toml +1 -1
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/_version_data/uv.lock +1 -1
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/activation_transaction.py +5 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/cache.py +6 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component-manifest.json +8 -8
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_capability_observation.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_capability_probe.py +6 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_cli.py +6 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_core_hooks.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_health.py +5 -5
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_health_support.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_hook_activation_expected.py +5 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_hook_activation_host.py +4 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_hook_activation_validation.py +2 -0
- getcodexy-1.13.2/src/codexy_runtime_tools/component_hook_registration.py +120 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_inspection.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_inspection_host.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_inspection_types.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_integrity.py +5 -5
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_interactive_probe.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_inventory_classification.py +7 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_inventory_records.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_admission.py +4 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_finish.py +5 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_interlock.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_mcp.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_operation.py +2 -2
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_preflight.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_recovery.py +3 -2
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_support.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle_terminal.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_manifest.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_manifest_dependencies.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_manifest_values.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_mcp_cache.py +4 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_registration_files.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_registration_health.py +5 -108
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_resolver.py +3 -1
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transaction_durability.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transaction_receipts.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transaction_snapshot.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transaction_state.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transition_journal.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transition_model.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transition_receipt.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transition_rejections.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_watcher_probe.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/contract.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/github_checks.py +4 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/github_pre_session.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/identity.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/installer.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/local_marketplace.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/marketplace_identity.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/marketplace_repin.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/monolith_baseline.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/monolith_classifier.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/monolith_migration.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/monolith_migration_host.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/monolith_migration_plan.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/package.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/package_archive.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/plugin_resolution.py +3 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/pre_session.py +2 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/release_contract_validation.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/runtime.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/runtime_configuration.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/source.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/title_policy.py +1 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/updater.py +1 -1
- {getcodexy-1.13.0 → getcodexy-1.13.2}/uv.lock +1 -1
- {getcodexy-1.13.0 → getcodexy-1.13.2}/README.md +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/__init__.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_capability_probe_process.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_hook_activation.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_lifecycle.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_mcp_materialization.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_mcp_probe.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_observed_inventory.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_registration_catalog.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_transaction_identity.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/mcp_bootstrap.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/monolith_migration_state.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/release_provenance.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/release_watcher_contract.py +0 -0
- {getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/version_lock.py +0 -0
|
@@ -14,6 +14,7 @@ _PACKAGED_LOCK = _ROOT / "src/codexy_runtime_tools/_version_data/uv.lock"
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _materialize_version_lock() -> None:
|
|
17
|
+
"""Copy the canonical project lock into the package payload before builds."""
|
|
17
18
|
contents = _CANONICAL_LOCK.read_bytes()
|
|
18
19
|
if not contents:
|
|
19
20
|
raise ValueError("packages/getcodexy/uv.lock must not be empty")
|
|
@@ -5,7 +5,7 @@ backend-path = ["."]
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "getcodexy"
|
|
8
|
-
version = "1.13.
|
|
8
|
+
version = "1.13.2"
|
|
9
9
|
description = "A Codex harness for coordinating agents through planning, implementation, verification, and review."
|
|
10
10
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -22,6 +22,7 @@ class ActivationSnapshot:
|
|
|
22
22
|
|
|
23
23
|
@classmethod
|
|
24
24
|
def capture(cls, home: Path) -> "ActivationSnapshot":
|
|
25
|
+
"""Capture only Codexy activation paths and named config backups."""
|
|
25
26
|
if not home.exists():
|
|
26
27
|
return cls(home, ())
|
|
27
28
|
files: list[Entry] = []
|
|
@@ -36,6 +37,7 @@ class ActivationSnapshot:
|
|
|
36
37
|
return cls(home, tuple(files))
|
|
37
38
|
|
|
38
39
|
def restore(self) -> None:
|
|
40
|
+
"""Rebuild the captured paths without following links or overwriting files."""
|
|
39
41
|
for root in (
|
|
40
42
|
Path("agents/codexy-github"),
|
|
41
43
|
Path("agents/codexy"),
|
|
@@ -47,6 +49,7 @@ class ActivationSnapshot:
|
|
|
47
49
|
_remove_tree(backup)
|
|
48
50
|
if self.entries:
|
|
49
51
|
self.home.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
# Recreate parents before children; O_EXCL rejects unexpected replacements.
|
|
50
53
|
for entry in sorted(self.entries, key=lambda item: len(item.relative.parts)):
|
|
51
54
|
path = self.home / entry.relative
|
|
52
55
|
if entry.data is None:
|
|
@@ -84,6 +87,7 @@ def _capture_directory(relative: Path, descriptor: int) -> list[Entry]:
|
|
|
84
87
|
_require_safe(metadata, relative)
|
|
85
88
|
entries = [Entry(relative, None, stat.S_IMODE(metadata.st_mode))]
|
|
86
89
|
for name in sorted(os.listdir(descriptor)):
|
|
90
|
+
# Inspect children without following links, then confirm each opened inode.
|
|
87
91
|
child = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
|
|
88
92
|
child_relative = relative / name
|
|
89
93
|
_require_safe(child, child_relative)
|
|
@@ -174,6 +178,7 @@ def _open_directory(path: Path, expected: os.stat_result) -> int:
|
|
|
174
178
|
|
|
175
179
|
|
|
176
180
|
def _require_safe(metadata: os.stat_result, path: Path) -> None:
|
|
181
|
+
"""Reject links, multiply-linked files, and special files in activation state."""
|
|
177
182
|
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
178
183
|
if (
|
|
179
184
|
stat.S_ISLNK(metadata.st_mode)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Validate plugin release identities and derive runtime cache keys."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import hashlib
|
|
@@ -15,6 +17,7 @@ SEMVER = re.compile(
|
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
def plugin_release(manifest_path: Path, package_override: bool = False) -> str:
|
|
20
|
+
"""Return a valid manifest version or the explicit package-override identity."""
|
|
18
21
|
if not manifest_path.is_file():
|
|
19
22
|
if package_override:
|
|
20
23
|
return "package-override"
|
|
@@ -29,6 +32,7 @@ def plugin_release(manifest_path: Path, package_override: bool = False) -> str:
|
|
|
29
32
|
def releases_match(
|
|
30
33
|
expected_manifest: Path, observed_manifest: Path
|
|
31
34
|
) -> tuple[bool, str]:
|
|
35
|
+
"""Compare release versions while retaining safe mismatch text."""
|
|
32
36
|
try:
|
|
33
37
|
expected = plugin_release(expected_manifest)
|
|
34
38
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
@@ -54,7 +58,9 @@ def releases_match(
|
|
|
54
58
|
def runtime_cache_key(
|
|
55
59
|
*, manifest: Path, package_override: bool, identity: list[str]
|
|
56
60
|
) -> str:
|
|
61
|
+
"""Bind cache identity to package release so an upgrade cannot reuse old state."""
|
|
57
62
|
release = plugin_release(manifest, package_override)
|
|
63
|
+
# NUL delimiters prevent adjacent identity components from becoming ambiguous.
|
|
58
64
|
digest_input = "\0".join(
|
|
59
65
|
("codexy.runtime-cache/v2", *identity[:-1], release, identity[-1])
|
|
60
66
|
)
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
{
|
|
30
30
|
"id": "core",
|
|
31
31
|
"plugin": "codexy",
|
|
32
|
-
"version": "1.13.
|
|
32
|
+
"version": "1.13.2",
|
|
33
33
|
"dependencies": [],
|
|
34
34
|
"asset": {
|
|
35
35
|
"pluginId": "codexy@codexy",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
{
|
|
64
64
|
"id": "github",
|
|
65
65
|
"plugin": "codexy-github",
|
|
66
|
-
"version": "1.13.
|
|
66
|
+
"version": "1.13.2",
|
|
67
67
|
"dependencies": [
|
|
68
68
|
"core"
|
|
69
69
|
],
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
{
|
|
80
80
|
"id": "devtools",
|
|
81
81
|
"plugin": "codexy-devtools",
|
|
82
|
-
"version": "1.13.
|
|
82
|
+
"version": "1.13.2",
|
|
83
83
|
"dependencies": [
|
|
84
84
|
"core"
|
|
85
85
|
],
|
|
@@ -97,27 +97,27 @@
|
|
|
97
97
|
"compatibleCombinations": [
|
|
98
98
|
{
|
|
99
99
|
"components": [],
|
|
100
|
-
"version": "1.13.
|
|
100
|
+
"version": "1.13.2"
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"components": [
|
|
104
104
|
"core"
|
|
105
105
|
],
|
|
106
|
-
"version": "1.13.
|
|
106
|
+
"version": "1.13.2"
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"components": [
|
|
110
110
|
"core",
|
|
111
111
|
"github"
|
|
112
112
|
],
|
|
113
|
-
"version": "1.13.
|
|
113
|
+
"version": "1.13.2"
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
"components": [
|
|
117
117
|
"core",
|
|
118
118
|
"devtools"
|
|
119
119
|
],
|
|
120
|
-
"version": "1.13.
|
|
120
|
+
"version": "1.13.2"
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
123
|
"components": [
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"github",
|
|
126
126
|
"devtools"
|
|
127
127
|
],
|
|
128
|
-
"version": "1.13.
|
|
128
|
+
"version": "1.13.2"
|
|
129
129
|
}
|
|
130
130
|
]
|
|
131
131
|
}
|
{getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_capability_observation.py
RENAMED
|
@@ -39,6 +39,7 @@ def component_observations(
|
|
|
39
39
|
name: _entry(configured, source=_REGISTRATION_SOURCE, scope=_REGISTRATION_SCOPE)
|
|
40
40
|
for name in CAPABILITIES.get(component, ())
|
|
41
41
|
}
|
|
42
|
+
# Registration establishes configuration only; absent direct probes stay unknown.
|
|
42
43
|
if not probe:
|
|
43
44
|
return observations
|
|
44
45
|
direct = probe.get("_capability_probes")
|
|
@@ -94,6 +95,7 @@ def _entry(
|
|
|
94
95
|
source: str,
|
|
95
96
|
scope: str,
|
|
96
97
|
) -> dict[str, object]:
|
|
98
|
+
"""Keep unobserved capability states unknown and never infer verification."""
|
|
97
99
|
return {
|
|
98
100
|
"states": {
|
|
99
101
|
"configured": "configured" if configured else UNKNOWN,
|
{getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_capability_probe.py
RENAMED
|
@@ -57,6 +57,7 @@ def _request(method, identifier=None, params=None):
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def probe_component(component, plugin, record):
|
|
60
|
+
"""Probe one installed component, keeping core hook and watcher checks linked."""
|
|
60
61
|
base = _base(record)
|
|
61
62
|
if plugin is None:
|
|
62
63
|
return base
|
|
@@ -67,6 +68,7 @@ def probe_component(component, plugin, record):
|
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def _probe_core(plugin, base):
|
|
71
|
+
# The watcher probe depends on a callable core hook; merge only observed results.
|
|
70
72
|
hook = _probe_hook("core", plugin, base)
|
|
71
73
|
if not hook.get("started") or not hook.get("callable"):
|
|
72
74
|
return hook
|
|
@@ -80,6 +82,7 @@ def _probe_core(plugin, base):
|
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
def _probe_hook(component, plugin, base):
|
|
85
|
+
"""Execute a registered hook and validate its event-specific response."""
|
|
83
86
|
event, marker = HOOK_SPECS[component]
|
|
84
87
|
capability = f"hook:{marker}"
|
|
85
88
|
payload = {"prompt": "review GitHub issue 723"}
|
|
@@ -121,6 +124,7 @@ def _probe_hook(component, plugin, base):
|
|
|
121
124
|
|
|
122
125
|
|
|
123
126
|
def _registered_hook(plugin, event, marker):
|
|
127
|
+
"""Return the platform-specific command for the registered marker, if any."""
|
|
124
128
|
try:
|
|
125
129
|
hooks = json.loads((plugin / "hooks/hooks.json").read_text(encoding="utf-8"))
|
|
126
130
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
@@ -163,6 +167,7 @@ def _argv(command, plugin, args=()):
|
|
|
163
167
|
and values[0].lower().endswith((".bat", ".cmd"))
|
|
164
168
|
and os.path.isfile(values[0])
|
|
165
169
|
):
|
|
170
|
+
# Batch files need cmd.exe quoting; other launchers remain an argv sequence.
|
|
166
171
|
shell = os.environ.get("COMSPEC", "cmd.exe")
|
|
167
172
|
command = f'"{values[0]}" {subprocess.list2cmdline(values[1:])}'.rstrip()
|
|
168
173
|
return f'{subprocess.list2cmdline([shell])} /d /s /c "{command}"'
|
|
@@ -178,6 +183,7 @@ def probe_reason(probe, default):
|
|
|
178
183
|
|
|
179
184
|
|
|
180
185
|
def identity_matches(manifest, component, record, probe):
|
|
186
|
+
"""Require the registered release to match each probed runtime."""
|
|
181
187
|
names = probe.get("runtime_names")
|
|
182
188
|
versions = probe.get("runtime_versions")
|
|
183
189
|
return (
|
|
@@ -20,6 +20,7 @@ from .version_lock import default_package_version
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
"""Dispatch lifecycle and inspection commands to their stable receipt forms."""
|
|
23
24
|
parser = argparse.ArgumentParser(prog="getcodexy", allow_abbrev=False)
|
|
24
25
|
parser.add_argument(
|
|
25
26
|
"--codex",
|
|
@@ -68,6 +69,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
68
69
|
arguments.codex,
|
|
69
70
|
)
|
|
70
71
|
except PreAdmissionError as error:
|
|
72
|
+
# Host admission failures use machine receipts only where JSON was requested.
|
|
71
73
|
if arguments.command == "bootstrap" and arguments.json_output:
|
|
72
74
|
print(json.dumps(_bootstrap_host_failure(), sort_keys=True))
|
|
73
75
|
return 2
|
|
@@ -92,6 +94,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
92
94
|
|
|
93
95
|
|
|
94
96
|
def _record_capability_failures(receipt: dict[str, object]) -> None:
|
|
97
|
+
"""Add each known live capability failure once to doctor/status errors."""
|
|
95
98
|
errors = receipt.get("errors")
|
|
96
99
|
health = receipt.get("component_health")
|
|
97
100
|
if not isinstance(errors, list) or not isinstance(health, list):
|
|
@@ -140,6 +143,7 @@ def _human(command: str, receipt: dict[str, object]) -> str:
|
|
|
140
143
|
if command in {"install", "update", "remove", "bootstrap"} and receipt.get(
|
|
141
144
|
"outcome"
|
|
142
145
|
) in {"completed", "pending-action"}:
|
|
146
|
+
# A new Codex task is needed because the host loads installed tools at startup.
|
|
143
147
|
return (
|
|
144
148
|
f"getcodexy {command}: {receipt['outcome']}; "
|
|
145
149
|
"start a fresh Codex task to refresh installed plugins and MCP tools"
|
|
@@ -165,6 +169,7 @@ def _human(command: str, receipt: dict[str, object]) -> str:
|
|
|
165
169
|
|
|
166
170
|
|
|
167
171
|
def _bootstrap_host_failure() -> dict[str, object]:
|
|
172
|
+
"""Encode trusted-host admission rejection using the bootstrap receipt schema."""
|
|
168
173
|
rejection = Rejection.from_failure(
|
|
169
174
|
RejectionStage.HOST, StateFailure.INCONSISTENT_INSTALLED_STATE
|
|
170
175
|
)
|
|
@@ -174,6 +179,7 @@ def _bootstrap_host_failure() -> dict[str, object]:
|
|
|
174
179
|
|
|
175
180
|
|
|
176
181
|
def _migration_host_failure() -> dict[str, object]:
|
|
182
|
+
"""Return the equivalent machine-readable migration rejection without running it."""
|
|
177
183
|
return {
|
|
178
184
|
"schema": "getcodexy.monolith-migration-receipt.v1",
|
|
179
185
|
"command": "migrate",
|
|
@@ -14,6 +14,8 @@ LAUNCHERS = tuple(
|
|
|
14
14
|
for _, stem in (*COMMAND_HOOKS, *LIFECYCLE_HOOKS)
|
|
15
15
|
for extension in ("sh", "cmd")
|
|
16
16
|
)
|
|
17
|
+
# Windows invokes the event script with `py.exe -3 -I -B`, bypassing the POSIX
|
|
18
|
+
# resolver; keep the entrypoint and sibling event helper in this required set.
|
|
17
19
|
DEPENDENCIES = (
|
|
18
20
|
"hooks/codexy-hook-runtime.sh",
|
|
19
21
|
"hooks/codexy-thread-delivery.py",
|
|
@@ -30,4 +32,5 @@ DEPENDENCIES = (
|
|
|
30
32
|
"hooks/codexy-watcher-interrupt.sh",
|
|
31
33
|
"hooks/codexy-watcher-interrupt.cmd",
|
|
32
34
|
"hooks/codexy_watcher_interrupt.py",
|
|
35
|
+
"hooks/codexy_watcher_interrupt_events.py",
|
|
33
36
|
)
|
|
@@ -41,7 +41,7 @@ def health(
|
|
|
41
41
|
activation: dict[str, str] | None = None,
|
|
42
42
|
codex_home=None,
|
|
43
43
|
) -> list[dict[str, object]]:
|
|
44
|
-
expected = set(recorded or ()) | set(actual)
|
|
44
|
+
expected = set(recorded or ()) | set(actual) # Include known and live components.
|
|
45
45
|
context = (
|
|
46
46
|
manifest,
|
|
47
47
|
actual,
|
|
@@ -118,7 +118,7 @@ def _component_health(context, component):
|
|
|
118
118
|
(not installed, "installed", "component-not-installed"),
|
|
119
119
|
(not configured, "configured", "component-not-configured"),
|
|
120
120
|
)
|
|
121
|
-
for failed, stage, reason in checks:
|
|
121
|
+
for failed, stage, reason in checks: # Report the earliest failure.
|
|
122
122
|
if failed:
|
|
123
123
|
return _mark(result, stage, reason)
|
|
124
124
|
if activation is not None and component in activation:
|
|
@@ -155,7 +155,7 @@ def _component_health(context, component):
|
|
|
155
155
|
|
|
156
156
|
|
|
157
157
|
def _mark(result: dict[str, object], stage: str, reason: str) -> dict[str, object]:
|
|
158
|
-
fallback, restart = FAILURES[reason]
|
|
158
|
+
fallback, restart = FAILURES[reason] # Map the reason to recovery guidance.
|
|
159
159
|
result.update(
|
|
160
160
|
first_failure_stage=stage,
|
|
161
161
|
reason_code=reason,
|
|
@@ -173,7 +173,7 @@ def _mark(result: dict[str, object], stage: str, reason: str) -> dict[str, objec
|
|
|
173
173
|
|
|
174
174
|
|
|
175
175
|
def _registration_roles(home, root, marker, expected, read_regular, max_bytes):
|
|
176
|
-
roles, managed, unmanaged = [], [], []
|
|
176
|
+
roles, managed, unmanaged = [], [], [] # Track expected and marker-owned roles.
|
|
177
177
|
for name, value in expected.items():
|
|
178
178
|
roles.append(
|
|
179
179
|
_registered_role(home, root, name, value, marker, read_regular, max_bytes)
|
|
@@ -219,7 +219,7 @@ def _registered_role(
|
|
|
219
219
|
read_regular,
|
|
220
220
|
max_bytes: int,
|
|
221
221
|
) -> dict[str, object]:
|
|
222
|
-
try:
|
|
222
|
+
try: # Inspect the marker-owned role file as a bounded regular input.
|
|
223
223
|
current = (
|
|
224
224
|
read_regular(home, root.relative_to(home) / name, max_bytes)
|
|
225
225
|
.decode()
|
|
@@ -36,6 +36,7 @@ def _legacy_state(
|
|
|
36
36
|
host_error,
|
|
37
37
|
codex_home=None,
|
|
38
38
|
):
|
|
39
|
+
"""Preserve the coarse state contract while checking installed package integrity."""
|
|
39
40
|
record = records.get(component)
|
|
40
41
|
if admission_error or host_error:
|
|
41
42
|
return "incompatible"
|
|
@@ -94,6 +95,7 @@ def _observed(record):
|
|
|
94
95
|
|
|
95
96
|
|
|
96
97
|
def _authority_valid(record):
|
|
98
|
+
"""Accept explicit attestation or recognized local marketplace forms."""
|
|
97
99
|
if not record:
|
|
98
100
|
return False
|
|
99
101
|
key = next((key for key in AUTHORITY_KEYS if key in record), None)
|
|
@@ -128,6 +130,7 @@ def _authority_valid(record):
|
|
|
128
130
|
|
|
129
131
|
|
|
130
132
|
def version_relation(manifest, record):
|
|
133
|
+
"""Treat unparseable installed versions as incompatible, never as a match."""
|
|
131
134
|
try:
|
|
132
135
|
return compare_versions(record_version(record), manifest.version)
|
|
133
136
|
except ComponentResolutionError:
|
{getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_hook_activation_expected.py
RENAMED
|
@@ -32,6 +32,8 @@ _EVENT_KEYS = {
|
|
|
32
32
|
|
|
33
33
|
@dataclass(frozen=True)
|
|
34
34
|
class ExpectedHook:
|
|
35
|
+
"""Installed hook identity and settings used to verify the host's active row."""
|
|
36
|
+
|
|
35
37
|
component: str
|
|
36
38
|
plugin_id: str
|
|
37
39
|
version: str
|
|
@@ -48,6 +50,7 @@ class ExpectedHook:
|
|
|
48
50
|
|
|
49
51
|
@property
|
|
50
52
|
def key(self) -> str:
|
|
53
|
+
"""Keep duplicate registrations distinct by their declared array positions."""
|
|
51
54
|
return (
|
|
52
55
|
f"{self.plugin_id}:hooks/hooks.json:{self.event_key}:"
|
|
53
56
|
f"{self.group_index}:{self.hook_index}"
|
|
@@ -59,6 +62,7 @@ def expected_hooks(
|
|
|
59
62
|
components: tuple[str, ...],
|
|
60
63
|
records: dict[str, dict[str, object]],
|
|
61
64
|
) -> dict[str, tuple[ExpectedHook, ...]]:
|
|
65
|
+
"""Parse installed manifests into exact, platform-specific hook expectations."""
|
|
62
66
|
expected: dict[str, tuple[ExpectedHook, ...]] = {}
|
|
63
67
|
for component in components:
|
|
64
68
|
record = records.get(component)
|
|
@@ -81,6 +85,7 @@ def expected_hooks(
|
|
|
81
85
|
raise HookStateError("installed hook registration has an invalid shape")
|
|
82
86
|
hooks_map = cast(dict[str, object], hooks)
|
|
83
87
|
entries: list[ExpectedHook] = []
|
|
88
|
+
# Event, positions, and command variant bind each host row to its source.
|
|
84
89
|
for event, groups_value in hooks_map.items():
|
|
85
90
|
if event not in _EVENT_LABELS or event not in _EVENT_KEYS:
|
|
86
91
|
raise HookStateError("installed hook registration has an unknown event")
|
{getcodexy-1.13.0 → getcodexy-1.13.2}/src/codexy_runtime_tools/component_hook_activation_host.py
RENAMED
|
@@ -43,6 +43,7 @@ def list_hooks(executable: Path, codex_home: Path) -> tuple[dict[str, object], .
|
|
|
43
43
|
"unable to determine the inspected working directory"
|
|
44
44
|
) from error
|
|
45
45
|
environment = os.environ.copy()
|
|
46
|
+
# Drop inherited variables that can redirect Git or Python during host startup.
|
|
46
47
|
for name in _ENVIRONMENT_UNSAFE:
|
|
47
48
|
_ = environment.pop(name, None)
|
|
48
49
|
environment.update(
|
|
@@ -132,6 +133,7 @@ def list_hooks(executable: Path, codex_home: Path) -> tuple[dict[str, object], .
|
|
|
132
133
|
pass
|
|
133
134
|
cleanup_failed = False
|
|
134
135
|
if process.poll() is None:
|
|
136
|
+
# Stop the Windows process tree; POSIX cleans up the direct child.
|
|
135
137
|
if os.name == "nt":
|
|
136
138
|
try:
|
|
137
139
|
_ = process.wait(timeout=1)
|
|
@@ -194,6 +196,7 @@ def normalize_hook_rows(value: object) -> tuple[dict[str, object], ...]:
|
|
|
194
196
|
if isinstance(hooks, list):
|
|
195
197
|
value = cast(list[object], hooks)
|
|
196
198
|
elif isinstance(data, list):
|
|
199
|
+
# Host versions may group hooks by CWD; reject any group carrying warnings.
|
|
197
200
|
flattened: list[dict[str, object]] = []
|
|
198
201
|
for item_value in cast(list[object], data):
|
|
199
202
|
if not isinstance(item_value, dict):
|
|
@@ -223,6 +226,7 @@ def normalize_hook_rows(value: object) -> tuple[dict[str, object], ...]:
|
|
|
223
226
|
|
|
224
227
|
|
|
225
228
|
def _response(values: queue.Queue[object], identifier: int) -> dict[str, object]:
|
|
229
|
+
"""Wait for the requested JSON-RPC id within this call's timeout window."""
|
|
226
230
|
deadline = time.monotonic() + HOOK_LIST_TIMEOUT_SECONDS
|
|
227
231
|
while True:
|
|
228
232
|
remaining = deadline - time.monotonic()
|
|
@@ -16,6 +16,7 @@ def classify_activation(
|
|
|
16
16
|
*,
|
|
17
17
|
codex_home: Path | None = None,
|
|
18
18
|
) -> dict[str, str]:
|
|
19
|
+
"""Require one enabled, trusted host row matching each installed hook exactly."""
|
|
19
20
|
observed = normalize_hook_rows(rows)
|
|
20
21
|
by_key: dict[str, list[dict[str, object]]] = {}
|
|
21
22
|
for row in observed:
|
|
@@ -38,6 +39,7 @@ def classify_activation(
|
|
|
38
39
|
component_keys = {
|
|
39
40
|
row.get("key") for row in component_rows if isinstance(row.get("key"), str)
|
|
40
41
|
}
|
|
42
|
+
# Extra rows indicate stale host state, even when required rows match.
|
|
41
43
|
if component_keys - required_keys:
|
|
42
44
|
failures[component] = "required-hook-trust-stale"
|
|
43
45
|
continue
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Canonical hook manifests for installed Codexy components."""
|
|
2
|
+
|
|
3
|
+
from .component_core_hooks import (
|
|
4
|
+
COMMAND_HOOKS as _CORE_COMMAND_HOOKS,
|
|
5
|
+
LIFECYCLE_HOOKS as _CORE_LIFECYCLE_HOOKS,
|
|
6
|
+
LAUNCHERS as CORE_HOOK_LAUNCHERS,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _hook(command: str, windows: str, timeout: int) -> dict[str, object]:
|
|
11
|
+
return {
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": command,
|
|
14
|
+
"commandWindows": windows,
|
|
15
|
+
"timeout": timeout,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _command_hook(matcher: str, launcher_stem: str, event: str) -> dict[str, object]:
|
|
20
|
+
command = f'"${{PLUGIN_ROOT}}/hooks/{launcher_stem}'
|
|
21
|
+
return {
|
|
22
|
+
"matcher": matcher,
|
|
23
|
+
"hooks": [_hook(f'{command}.sh" {event}', f'{command}.cmd" {event}', 5)],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _lifecycle_hook(launcher_stem: str, event: str) -> dict[str, object]:
|
|
28
|
+
command = f'"${{PLUGIN_ROOT}}/hooks/{launcher_stem}'
|
|
29
|
+
return {"hooks": [_hook(f'{command}.sh" {event}', f'{command}.cmd" {event}', 3)]}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _bash_hooks(event: str) -> list[dict[str, object]]:
|
|
33
|
+
return [_command_hook("^Bash$", "codexy-destructive-command", event)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _title_hooks(event: str) -> list[dict[str, object]]:
|
|
37
|
+
return [_title_hook(matcher, kind, event) for matcher, kind in _TITLE_MATCHERS]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _title_hook(matcher: str, kind: str, event: str) -> dict[str, object]:
|
|
41
|
+
return {
|
|
42
|
+
"matcher": matcher,
|
|
43
|
+
"hooks": [
|
|
44
|
+
_hook(
|
|
45
|
+
f'"${{PLUGIN_ROOT}}/hooks/codexy-title-check.sh" {event} {kind}',
|
|
46
|
+
f'"${{PLUGIN_ROOT}}/hooks/codexy-title-check.cmd" {event} {kind}',
|
|
47
|
+
5,
|
|
48
|
+
)
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_TITLE_MATCHERS = (
|
|
54
|
+
(
|
|
55
|
+
"^(?:mcp__codex_apps__github_(?:create|update)_issue|github\\.(?:create|update)_issue)$",
|
|
56
|
+
"issue",
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"^(?:mcp__codex_apps__github_(?:create|update)_pull_request|github\\.(?:create|update)_pull_request)$",
|
|
60
|
+
"pr",
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
"^(?:mcp__codex_apps__github_(?:merge_pull_request|enable_auto_merge)|github\\.(?:merge_pull_request|enable_auto_merge))$",
|
|
64
|
+
"merge",
|
|
65
|
+
),
|
|
66
|
+
("^functions\\.exec$", "nested"),
|
|
67
|
+
("^Bash$", "shell"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _core_hooks() -> dict[str, object]:
|
|
72
|
+
"""Build core event groups, keeping interrupt and prompt callbacks matcher-free."""
|
|
73
|
+
command = lambda event: [
|
|
74
|
+
_command_hook(matcher, stem, event) for matcher, stem in _CORE_COMMAND_HOOKS
|
|
75
|
+
]
|
|
76
|
+
lifecycle = lambda event: [
|
|
77
|
+
_command_hook(matcher, stem, event) for matcher, stem in _CORE_LIFECYCLE_HOOKS
|
|
78
|
+
]
|
|
79
|
+
return {
|
|
80
|
+
"hooks": {
|
|
81
|
+
"PermissionRequest": command("PermissionRequest"),
|
|
82
|
+
"PreToolUse": command("PreToolUse") + lifecycle("PreToolUse"),
|
|
83
|
+
"Interrupt": [
|
|
84
|
+
_lifecycle_hook(stem, "Interrupt") for _, stem in _CORE_LIFECYCLE_HOOKS
|
|
85
|
+
],
|
|
86
|
+
"UserPromptSubmit": [
|
|
87
|
+
_lifecycle_hook(stem, "UserPromptSubmit")
|
|
88
|
+
for _, stem in _CORE_LIFECYCLE_HOOKS
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _github_hooks() -> dict[str, object]:
|
|
95
|
+
context = '"${PLUGIN_ROOT}/hooks/codexy-github-workflow-context'
|
|
96
|
+
return {
|
|
97
|
+
"hooks": {
|
|
98
|
+
"UserPromptSubmit": [
|
|
99
|
+
{"hooks": [_hook(f'{context}.sh"', f'{context}.cmd"', 5)]}
|
|
100
|
+
],
|
|
101
|
+
"PermissionRequest": _title_hooks("PermissionRequest")
|
|
102
|
+
+ _bash_hooks("PermissionRequest"),
|
|
103
|
+
"PreToolUse": _title_hooks("PreToolUse") + _bash_hooks("PreToolUse"),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
HOOKS = {"core": _core_hooks(), "github": _github_hooks()}
|
|
109
|
+
LAUNCHERS = {
|
|
110
|
+
"core": CORE_HOOK_LAUNCHERS,
|
|
111
|
+
"github": (
|
|
112
|
+
"hooks/codexy-github-workflow-context.sh",
|
|
113
|
+
"hooks/codexy-github-workflow-context.cmd",
|
|
114
|
+
"hooks/codexy-title-check.sh",
|
|
115
|
+
"hooks/codexy-title-check.cmd",
|
|
116
|
+
"hooks/codexy-destructive-command.sh",
|
|
117
|
+
"hooks/codexy-destructive-command.cmd",
|
|
118
|
+
),
|
|
119
|
+
"devtools": ("mcp/codexy-mcp-devtools",),
|
|
120
|
+
}
|
|
@@ -104,6 +104,7 @@ def _inspect(
|
|
|
104
104
|
runner: Runner | None,
|
|
105
105
|
hook_lister: HookLister | None = None,
|
|
106
106
|
) -> InspectionReport:
|
|
107
|
+
"""Compare live host and hook state with recorded package data."""
|
|
107
108
|
home, manifest = _absolute(codex_home), load_component_manifest()
|
|
108
109
|
_validate_real_path(home, require_exists=False)
|
|
109
110
|
recorded, inventory, inventory_error = _recorded(home)
|
|
@@ -115,6 +116,7 @@ def _inspect(
|
|
|
115
116
|
host_error = probe.value if probe else None
|
|
116
117
|
if probe is None:
|
|
117
118
|
assert executable is not None and invoke is not None
|
|
119
|
+
# Installed-plugin and marketplace reads supply live authority for the report.
|
|
118
120
|
try:
|
|
119
121
|
installed = _json(
|
|
120
122
|
invoke([str(executable), "plugin", "list", "--json"]), "plugin list"
|
|
@@ -210,6 +212,7 @@ def _actual(
|
|
|
210
212
|
installed: object,
|
|
211
213
|
root: MarketplaceBinding | None,
|
|
212
214
|
) -> tuple[tuple[str, ...], dict[str, dict[str, object]], str | None]:
|
|
215
|
+
"""Reconcile parsed installed records with host-admitted canonical components."""
|
|
213
216
|
actual: tuple[str, ...] = ()
|
|
214
217
|
records: dict[str, dict[str, object]] = {}
|
|
215
218
|
try:
|
|
@@ -23,6 +23,7 @@ class ProbeStage(str, Enum):
|
|
|
23
23
|
def host(
|
|
24
24
|
home: Path, codex: Path | None, runner: Runner | None
|
|
25
25
|
) -> tuple[Path, Runner, None] | tuple[None, None, ProbeStage]:
|
|
26
|
+
"""Resolve Codex and return a stage-specific setup failure."""
|
|
26
27
|
try:
|
|
27
28
|
return (
|
|
28
29
|
trusted_codex(codex or _find_codex()),
|
|
@@ -41,8 +41,8 @@ def _verify_component(
|
|
|
41
41
|
def _read_component(
|
|
42
42
|
root: Path, verified: dict[Path, bytes], modes: dict[Path, int]
|
|
43
43
|
) -> None:
|
|
44
|
-
size = sum(map(len, verified.values()))
|
|
45
|
-
pending = [Path()]
|
|
44
|
+
size = sum(map(len, verified.values())) # The manifest shares the same cap.
|
|
45
|
+
pending = [Path()] # Traverse from the component root.
|
|
46
46
|
while pending:
|
|
47
47
|
relative_directory = pending.pop()
|
|
48
48
|
directory = root / relative_directory
|
|
@@ -94,7 +94,7 @@ def frozen_component(
|
|
|
94
94
|
)
|
|
95
95
|
with os.fdopen(descriptor, "wb") as output:
|
|
96
96
|
output.write(data)
|
|
97
|
-
os.chmod(destination, 0o600 | (modes.get(relative, 0) & 0o111))
|
|
97
|
+
os.chmod(destination, 0o600 | (modes.get(relative, 0) & 0o111)) # keep exec
|
|
98
98
|
yield target
|
|
99
99
|
|
|
100
100
|
|
|
@@ -177,7 +177,7 @@ def _read_regular_windows(
|
|
|
177
177
|
opened = os.fstat(descriptor)
|
|
178
178
|
if not stat.S_ISREG(opened.st_mode):
|
|
179
179
|
raise ValueError(f"component integrity requires regular files: {target}")
|
|
180
|
-
_, final = _windows_safe_path(root, relative)
|
|
180
|
+
_, final = _windows_safe_path(root, relative) # Recheck path after opening it.
|
|
181
181
|
if (opened.st_dev, opened.st_ino) != (final.st_dev, final.st_ino):
|
|
182
182
|
raise ValueError(
|
|
183
183
|
f"component integrity path changed while reading: {target}"
|
|
@@ -231,7 +231,7 @@ def _has_windows_reparse_point(metadata: os.stat_result) -> bool:
|
|
|
231
231
|
def valid_agent_toml(text: str, path: Path) -> bool:
|
|
232
232
|
try:
|
|
233
233
|
import tomllib
|
|
234
|
-
except ModuleNotFoundError:
|
|
234
|
+
except ModuleNotFoundError: # Keep validation dependency-free on older runtimes.
|
|
235
235
|
import ast
|
|
236
236
|
|
|
237
237
|
tree = ast.parse(text, filename=str(path))
|