agentic-devtools 0.2.596__py3-none-any.whl → 0.2.598__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.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.596'
22
- __version_tuple__ = version_tuple = (0, 2, 596)
21
+ __version__ = version = '0.2.598'
22
+ __version_tuple__ = version_tuple = (0, 2, 598)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -13,6 +13,9 @@ from collections.abc import Collection, Mapping, Sequence
13
13
  from pathlib import Path, PurePosixPath, PureWindowsPath
14
14
  from typing import Literal, NamedTuple, cast
15
15
 
16
+ from agentic_devtools.cli.ci import windows_specific_cli
17
+ from agentic_devtools.cli.ci.windows_specific_cli import _check_bindings, _run_cli, _unique_object
18
+
16
19
  __all__ = [
17
20
  "ProductGroup",
18
21
  "SelectorPolicy",
@@ -23,6 +26,7 @@ __all__ = [
23
26
  "run_selected_tests",
24
27
  "audit_test_results",
25
28
  "evaluate_gate",
29
+ "main",
26
30
  ]
27
31
 
28
32
  ProductGroup = Literal["setup-check", "portability", "benchmark"]
@@ -107,9 +111,14 @@ _CONTRACT_PATHS = {
107
111
  ".github/workflows/pr-smart-module-tests.yml",
108
112
  "tests/workflows/test_windows_specific_ci.py",
109
113
  "agentic_devtools/cli/ci/windows_specific.py",
114
+ "agentic_devtools/cli/ci/windows_specific_cli.py",
110
115
  "agentic_devtools/cli/ci/windows_fixture_bytes.py",
111
116
  }
112
- _CONTRACT_PREFIXES = ("tests/unit/cli/ci/windows_specific/", "tests/unit/cli/ci/windows_fixture_bytes/")
117
+ _CONTRACT_PREFIXES = (
118
+ "tests/unit/cli/ci/windows_specific/",
119
+ "tests/unit/cli/ci/windows_specific_cli/",
120
+ "tests/unit/cli/ci/windows_fixture_bytes/",
121
+ )
113
122
  _EXPECTATION_NAMES = {"EXPECTED_SELECTORS", "EXPECTED_EXPANSIONS"}
114
123
  _NODE_ID_PATTERN = re.compile(
115
124
  r"(?:(?!\.{1,2}(?:/|$))[^/\\:\s]+/)*(?!\.{1,2}(?:/|$))[^/\\:\s]+\.py"
@@ -153,7 +162,7 @@ def _parse_source(source: str) -> SelectorPolicy:
153
162
  if assignments.keys() != _EXPECTATION_NAMES:
154
163
  raise ValueError("Missing simple top-level expectation assignments")
155
164
 
156
- _check_bindings(tree, {id(statement.targets[0]) for statement in assignments.values()})
165
+ _check_bindings(tree, {id(statement.targets[0]) for statement in assignments.values()}, _EXPECTATION_NAMES)
157
166
 
158
167
  values = {}
159
168
  for name, statement in assignments.items():
@@ -171,38 +180,6 @@ def _parse_source(source: str) -> SelectorPolicy:
171
180
  return _validate_policy(values["EXPECTED_SELECTORS"], values["EXPECTED_EXPANSIONS"])
172
181
 
173
182
 
174
- def _check_bindings(tree: ast.Module, allowed_targets: set[int]) -> None:
175
- """Reject expectation bindings other than the two approved literal targets."""
176
- for node in ast.walk(tree):
177
- if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
178
- if node.id in _EXPECTATION_NAMES and id(node) not in allowed_targets:
179
- raise ValueError("Additional expectation binding or deletion")
180
- # Some Python bindings are string fields rather than Name(Store) nodes.
181
- names: Sequence[str | None]
182
- if isinstance(node, ast.alias):
183
- if node.name == "*":
184
- raise ValueError("Wildcard imports may rebind policy expectations")
185
- names = [node.asname or node.name.split(".")[0]]
186
- elif isinstance(node, ast.arg):
187
- names = [node.arg]
188
- elif isinstance(node, (ast.Global, ast.Nonlocal)):
189
- names = node.names
190
- else:
191
- names = [getattr(node, "name", None), getattr(node, "rest", None)]
192
- if any(name in _EXPECTATION_NAMES for name in names):
193
- raise ValueError("Additional expectation binding")
194
-
195
-
196
- def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
197
- """Reject duplicate JSON keys at every nesting level."""
198
- result: dict[str, object] = {}
199
- for key, value in pairs:
200
- if key in result:
201
- raise ValueError(f"Duplicate JSON object key: {key}")
202
- result[key] = value
203
- return result
204
-
205
-
206
183
  def load_policy(
207
184
  manifest_path: str | Path,
208
185
  base_policy_path: str | Path,
@@ -498,3 +475,24 @@ def evaluate_gate(
498
475
  ):
499
476
  return {"ok": False, "error_code": "required_evidence_missing"}
500
477
  return {"ok": True, "classification": "native-evidence" if groups else "not-applicable"}
478
+
479
+
480
+ def main(argv: Sequence[str] | None = None) -> int:
481
+ """Run the focused Windows CI command dispatcher with trusted APIs."""
482
+ policy_loader: windows_specific_cli._LoadPolicy[SelectorPolicy, ProductGroup] = load_policy
483
+ group_selector: windows_specific_cli._SelectGroups[ProductGroup] = select_groups
484
+ test_runner: windows_specific_cli._RunSelectedTests[SelectorPolicy, ProductGroup] = run_selected_tests
485
+ gate_evaluator: windows_specific_cli._EvaluateGate[Literal["success", "failure", "cancelled", "skipped"]] = (
486
+ evaluate_gate
487
+ )
488
+ return _run_cli(
489
+ argv,
490
+ load_policy=policy_loader,
491
+ select_groups=group_selector,
492
+ run_selected_tests=test_runner,
493
+ evaluate_gate=gate_evaluator,
494
+ )
495
+
496
+
497
+ if __name__ == "__main__":
498
+ raise SystemExit(main())
@@ -0,0 +1,362 @@
1
+ """Private command dispatch for trusted Windows-specific CI orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from collections.abc import Collection, Mapping, Sequence
12
+ from pathlib import Path
13
+ from typing import Literal, Never, Protocol, TypeVar, cast
14
+
15
+ from agentic_devtools.cli.ci.windows_fixture_bytes import verify_fixture_bytes
16
+
17
+ POut = TypeVar("POut", covariant=True)
18
+ PIn = TypeVar("PIn", contravariant=True)
19
+ GOut = TypeVar("GOut", bound=Literal["setup-check", "portability", "benchmark"], covariant=True)
20
+ GIn = TypeVar("GIn", bound=Literal["setup-check", "portability", "benchmark"], contravariant=True)
21
+ RIn = TypeVar("RIn", bound=Literal["success", "failure", "cancelled", "skipped"], contravariant=True)
22
+ _P = str | Path
23
+ type _G[T] = Collection[T]
24
+ _R = dict[str, bool | list[str] | int]
25
+ _S = Mapping[str, object]
26
+ _E = dict[str, bool | str]
27
+
28
+
29
+ class _LoadPolicy(Protocol[POut, GIn]):
30
+ def __call__(self, m: _P, b: _P, h: _P, /, *, applicable_groups: _G[GIn], bootstrap: bool) -> POut: ...
31
+
32
+
33
+ class _SelectGroups(Protocol[GOut]):
34
+ def __call__(self, changed_paths: Sequence[str]) -> tuple[frozenset[GOut], bool]: ...
35
+
36
+
37
+ class _RunSelectedTests(Protocol[PIn, GIn]):
38
+ def __call__(self, root: _P, policy: PIn, groups: _G[GIn], /, *, junit_path: _P) -> _R: ...
39
+
40
+
41
+ class _EvaluateGate(Protocol[RIn]):
42
+ def __call__(self, selection: _S, linux: RIn | None, windows: RIn | None, /) -> _E: ...
43
+
44
+
45
+ def _check_bindings(tree: ast.Module, allowed_targets: set[int], expectation_names: set[str]) -> None:
46
+ """Reject expectation bindings other than the approved literal targets."""
47
+ for node in ast.walk(tree):
48
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
49
+ if node.id in expectation_names and id(node) not in allowed_targets:
50
+ raise ValueError("Additional expectation binding or deletion")
51
+ # Some Python bindings are string fields rather than Name(Store) nodes.
52
+ names: Sequence[str | None]
53
+ if isinstance(node, ast.alias):
54
+ if node.name == "*":
55
+ raise ValueError("Wildcard imports may rebind policy expectations")
56
+ names = [node.asname or node.name.split(".")[0]]
57
+ elif isinstance(node, ast.arg):
58
+ names = [node.arg]
59
+ elif isinstance(node, (ast.Global, ast.Nonlocal)):
60
+ names = node.names
61
+ else:
62
+ names = [getattr(node, "name", None), getattr(node, "rest", None)]
63
+ if any(name in expectation_names for name in names):
64
+ raise ValueError("Additional expectation binding")
65
+
66
+
67
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
68
+ """Reject duplicate JSON keys at every nesting level."""
69
+ result: dict[str, object] = {}
70
+ for key, value in pairs:
71
+ if key in result:
72
+ raise ValueError(f"Duplicate JSON object key: {key}")
73
+ result[key] = value
74
+ return result
75
+
76
+
77
+ class _Dispatcher[
78
+ PolicyT,
79
+ GroupT: Literal["setup-check", "portability", "benchmark"],
80
+ ResultT: Literal["success", "failure", "cancelled", "skipped"],
81
+ ]:
82
+ def __init__(
83
+ self,
84
+ load_policy: _LoadPolicy[PolicyT, GroupT],
85
+ select_groups: _SelectGroups[GroupT],
86
+ run_selected_tests: _RunSelectedTests[PolicyT, GroupT],
87
+ evaluate_gate: _EvaluateGate[ResultT],
88
+ ) -> None:
89
+ self._load_policy = load_policy
90
+ self._select_groups = select_groups
91
+ self._run_selected_tests = run_selected_tests
92
+ self._evaluate_gate = evaluate_gate
93
+ self._product_group_order = ("setup-check", "portability", "benchmark")
94
+
95
+ def _emit(self, payload: Mapping[str, object], status: int = 0) -> int:
96
+ print(json.dumps(payload, separators=(",", ":")))
97
+ return status
98
+
99
+ def _fail(self, error_code: str, detail: object, status: int = 1) -> int:
100
+ print(str(detail), file=sys.stderr)
101
+ return self._emit({"ok": False, "error_code": error_code}, status)
102
+
103
+ def _missing_file_code(self, exc: FileNotFoundError, base_policy: str, manifest: str, head_policy: str) -> str:
104
+ if not isinstance(exc.filename, str):
105
+ return "policy_io_failed"
106
+ path = Path(exc.filename)
107
+ if path == Path(base_policy):
108
+ return "base_policy_missing"
109
+ if path == Path(manifest):
110
+ return "manifest_missing"
111
+ if path == Path(head_policy):
112
+ return "head_policy_missing"
113
+ return "policy_io_failed"
114
+
115
+ def _decode_json(self, raw: str, error_code: str) -> tuple[object | None, int | None]:
116
+ try:
117
+ return json.loads(raw, object_pairs_hook=_unique_object), None
118
+ except ValueError as exc:
119
+ return None, self._fail(error_code, exc)
120
+
121
+ def _retry_select_policy(
122
+ self, manifest: str, base_policy: str, head_policy: str, groups: frozenset[GroupT]
123
+ ) -> tuple[int, bool]:
124
+ try:
125
+ self._load_policy(
126
+ manifest,
127
+ base_policy,
128
+ head_policy,
129
+ applicable_groups=groups,
130
+ bootstrap=True,
131
+ )
132
+ except FileNotFoundError as exc:
133
+ return self._fail(self._missing_file_code(exc, base_policy, manifest, head_policy), exc), False
134
+ except ValueError as exc:
135
+ return self._fail("bootstrap_not_allowed", exc), False
136
+ except OSError as exc:
137
+ return self._fail("policy_io_failed", exc), False
138
+ return 0, True
139
+
140
+ def _select_policy(
141
+ self, manifest: str, base_policy: str, head_policy: str, groups: frozenset[GroupT], bootstrap: bool
142
+ ) -> tuple[int, bool]:
143
+ try:
144
+ self._load_policy(
145
+ manifest,
146
+ base_policy,
147
+ head_policy,
148
+ applicable_groups=groups,
149
+ bootstrap=False,
150
+ )
151
+ except FileNotFoundError as exc:
152
+ code = self._missing_file_code(exc, base_policy, manifest, head_policy)
153
+ if code == "base_policy_missing" and bootstrap and not groups:
154
+ return self._retry_select_policy(manifest, base_policy, head_policy, groups)
155
+ return self._fail(code, exc), False
156
+ except ValueError as exc:
157
+ return self._fail("policy_invalid", exc), False
158
+ except OSError as exc:
159
+ return self._fail("policy_io_failed", exc), False
160
+ if bootstrap:
161
+ return self._fail(
162
+ "bootstrap_not_allowed", "--bootstrap is not allowed when the trusted base policy exists"
163
+ ), False
164
+ return 0, False
165
+
166
+ def _select(self, args: argparse.Namespace) -> int:
167
+ changed_paths, status = self._decode_json(args.changed_paths_json, "changed_paths_failed")
168
+ if status is not None:
169
+ return status
170
+ if not isinstance(changed_paths, list) or any(not isinstance(path, str) for path in changed_paths):
171
+ return self._fail("changed_paths_failed", "Changed paths must be a JSON array of strings")
172
+ try:
173
+ applicable_groups, contract_required = self._select_groups(changed_paths)
174
+ except ValueError as exc:
175
+ return self._fail("changed_paths_failed", exc)
176
+ base_policy = os.environ.get("WINDOWS_CI_BASE_POLICY")
177
+ if not base_policy:
178
+ return self._fail("base_policy_unset", "WINDOWS_CI_BASE_POLICY must name the trusted base policy")
179
+ status, bootstrap_allowed = self._select_policy(
180
+ args.manifest, base_policy, args.head_policy, applicable_groups, args.bootstrap
181
+ )
182
+ if status:
183
+ return status
184
+ return self._emit(
185
+ {
186
+ "ok": True,
187
+ "product_groups": [group for group in self._product_group_order if group in applicable_groups],
188
+ "contract_required": contract_required,
189
+ "windows_required": bool(applicable_groups),
190
+ "bootstrap_allowed": bootstrap_allowed,
191
+ }
192
+ )
193
+
194
+ def _validate_groups(self, groups: object) -> list[GroupT] | None:
195
+ if not isinstance(groups, list):
196
+ return None
197
+ for group in groups:
198
+ if not isinstance(group, str) or group not in self._product_group_order:
199
+ return None
200
+ if len(set(groups)) != len(groups):
201
+ return None
202
+ if groups != [group for group in self._product_group_order if group in groups]:
203
+ return None
204
+ return cast(list[GroupT], groups)
205
+
206
+ def _validate_run_selection(self, selection: object) -> list[GroupT] | None:
207
+ keys = {"ok", "product_groups", "contract_required", "windows_required", "bootstrap_allowed"}
208
+ if not isinstance(selection, dict) or set(selection) != keys or selection.get("ok") is not True:
209
+ return None
210
+ groups = self._validate_groups(selection.get("product_groups"))
211
+ if groups is None:
212
+ return None
213
+ if any(
214
+ not isinstance(selection.get(key), bool)
215
+ for key in ("contract_required", "windows_required", "bootstrap_allowed")
216
+ ):
217
+ return None
218
+ if selection["windows_required"] != bool(groups):
219
+ return None
220
+ if groups and selection["bootstrap_allowed"]:
221
+ return None
222
+ return groups
223
+
224
+ def _load_run_policy(
225
+ self, manifest: str, base_policy: str, head_policy: str, groups: list[GroupT]
226
+ ) -> tuple[int, PolicyT | None]:
227
+ try:
228
+ policy = self._load_policy(
229
+ manifest,
230
+ base_policy,
231
+ head_policy,
232
+ applicable_groups=groups,
233
+ bootstrap=False,
234
+ )
235
+ except FileNotFoundError as exc:
236
+ return self._fail(self._missing_file_code(exc, base_policy, manifest, head_policy), exc), None
237
+ except ValueError as exc:
238
+ return self._fail("policy_invalid", exc), None
239
+ except OSError as exc:
240
+ return self._fail("policy_io_failed", exc), None
241
+ return 0, policy
242
+
243
+ def _execute_tests(self, args: argparse.Namespace, policy: PolicyT, groups: list[GroupT]) -> int:
244
+ try:
245
+ result = self._run_selected_tests(args.root, policy, groups, junit_path=args.junit)
246
+ except subprocess.CalledProcessError as exc:
247
+ detail = f"Test subprocess failed with exit status {exc.returncode}: {exc}"
248
+ if exc.output:
249
+ detail += f"\nstdout:\n{exc.output}"
250
+ if exc.stderr:
251
+ detail += f"\nstderr:\n{exc.stderr}"
252
+ return self._fail("test_process_failed", detail, exc.returncode)
253
+ except ValueError as exc:
254
+ return self._fail("test_validation_failed", exc)
255
+ except OSError as exc:
256
+ return self._fail("test_io_failed", exc)
257
+ return self._emit(
258
+ {
259
+ "ok": True,
260
+ "collected_ids": result["collected_ids"],
261
+ "executed_ids": result["executed_ids"],
262
+ "skipped": result["skipped"],
263
+ }
264
+ )
265
+
266
+ def _run(self, args: argparse.Namespace) -> int:
267
+ selection, status = self._decode_json(args.selection_json, "selection_failed")
268
+ if status is not None:
269
+ return status
270
+ groups = self._validate_run_selection(selection)
271
+ if groups is None:
272
+ return self._fail("selection_failed", "Run requires a successful select result")
273
+ if not groups:
274
+ return self._fail("no_product_groups", "No Windows product groups apply")
275
+ base_policy = os.environ.get("WINDOWS_CI_BASE_POLICY")
276
+ if not base_policy:
277
+ return self._fail("base_policy_unset", "WINDOWS_CI_BASE_POLICY must name the trusted base policy")
278
+ status, policy = self._load_run_policy(args.manifest, base_policy, args.head_policy, groups)
279
+ if status:
280
+ return status
281
+ return self._execute_tests(args, cast(PolicyT, policy), groups)
282
+
283
+ def _gate(self, args: argparse.Namespace) -> int:
284
+ selection, status = self._decode_json(args.selection_json, "selection_failed")
285
+ if status is not None:
286
+ return status
287
+ result = self._evaluate_gate(
288
+ cast(_S, selection),
289
+ cast(ResultT | None, args.linux_result),
290
+ cast(ResultT | None, args.windows_result),
291
+ )
292
+ return self._emit(result, 0 if result.get("ok") is True else 1)
293
+
294
+ def _verify_fixtures(self, args: argparse.Namespace) -> int:
295
+ try:
296
+ files_checked = verify_fixture_bytes(args.commit, Path(args.root))
297
+ except subprocess.CalledProcessError as exc:
298
+ detail = str(exc)
299
+ for label, output in (("stdout", exc.stdout), ("stderr", exc.stderr)):
300
+ if output:
301
+ detail += f"\n{label}:\n{os.fsdecode(output)}"
302
+ return self._fail("fixture_audit_failed", detail)
303
+ except (ValueError, FileNotFoundError, NotADirectoryError, OSError) as exc:
304
+ return self._fail("fixture_audit_failed", exc)
305
+ return self._emit({"ok": True, "files_checked": files_checked})
306
+
307
+ def run(self, argv: Sequence[str] | None) -> int:
308
+ class _JsonArgumentParser(argparse.ArgumentParser):
309
+ def error(self, message: str) -> Never:
310
+ self.print_usage(sys.stderr)
311
+ print(f"{self.prog}: error: {message}", file=sys.stderr)
312
+ print(json.dumps({"ok": False, "error_code": "invalid_arguments"}, separators=(",", ":")))
313
+ self.exit(2)
314
+
315
+ parser = _JsonArgumentParser(description="Run focused trusted Windows CI commands")
316
+ commands = parser.add_subparsers(dest="command", required=True)
317
+
318
+ select_parser = commands.add_parser("select")
319
+ select_parser.add_argument("--changed-paths-json", required=True)
320
+ select_parser.add_argument("--manifest", required=True)
321
+ select_parser.add_argument("--head-policy", required=True)
322
+ select_parser.add_argument("--bootstrap", action="store_true")
323
+
324
+ run_parser = commands.add_parser("run")
325
+ run_parser.add_argument("--selection-json", required=True)
326
+ run_parser.add_argument("--manifest", required=True)
327
+ run_parser.add_argument("--head-policy", required=True)
328
+ run_parser.add_argument("--root", required=True)
329
+ run_parser.add_argument("--junit", required=True)
330
+
331
+ gate_parser = commands.add_parser("gate")
332
+ gate_parser.add_argument("--selection-json", required=True)
333
+ gate_parser.add_argument("--linux-result")
334
+ gate_parser.add_argument("--windows-result")
335
+
336
+ fixture_parser = commands.add_parser("verify-fixtures")
337
+ fixture_parser.add_argument("--commit", required=True)
338
+ fixture_parser.add_argument("--root", required=True)
339
+
340
+ args = parser.parse_args(argv)
341
+ return {
342
+ "select": self._select,
343
+ "run": self._run,
344
+ "gate": self._gate,
345
+ "verify-fixtures": self._verify_fixtures,
346
+ }[args.command](args)
347
+
348
+
349
+ def _run_cli[
350
+ PolicyT,
351
+ GroupT: Literal["setup-check", "portability", "benchmark"],
352
+ ResultT: Literal["success", "failure", "cancelled", "skipped"],
353
+ ](
354
+ argv: Sequence[str] | None,
355
+ *,
356
+ load_policy: _LoadPolicy[PolicyT, GroupT],
357
+ select_groups: _SelectGroups[GroupT],
358
+ run_selected_tests: _RunSelectedTests[PolicyT, GroupT],
359
+ evaluate_gate: _EvaluateGate[ResultT],
360
+ ) -> int:
361
+ """Dispatch through the private typed command handler."""
362
+ return _Dispatcher(load_policy, select_groups, run_selected_tests, evaluate_gate).run(argv)
@@ -5,13 +5,19 @@ Checks all external CLI tools that ``agentic-devtools`` depends on and
5
5
  reports their availability, version, and install path in a formatted table.
6
6
  """
7
7
 
8
+ import json
9
+ import os
8
10
  import re
11
+ import shlex
9
12
  import shutil
10
13
  import subprocess
11
14
  import sys
12
15
  from dataclasses import dataclass, field
16
+ from importlib.metadata import PackageNotFoundError, distribution
13
17
  from pathlib import Path
14
18
  from typing import Any
19
+ from urllib.parse import urlsplit
20
+ from urllib.request import url2pathname
15
21
 
16
22
  from ..subprocess_utils import run_safe
17
23
  from .git_hooks_policy import is_git_hooks_management_enabled
@@ -128,23 +134,11 @@ def _find_binary(name: str) -> str | None:
128
134
  return shutil.which(name)
129
135
 
130
136
 
131
- def _check_dependency(
132
- name: str,
133
- *,
134
- required: bool,
135
- install_hint: str,
136
- category: str,
137
- ) -> DependencyStatus:
137
+ def _check_dependency(name: str, *, required: bool, install_hint: str, category: str) -> DependencyStatus:
138
138
  """Check a single dependency and return its :class:`DependencyStatus`."""
139
139
  path = _find_binary(name)
140
140
  if not path:
141
- return DependencyStatus(
142
- name=name,
143
- found=False,
144
- required=required,
145
- install_hint=install_hint,
146
- category=category,
147
- )
141
+ return DependencyStatus(name=name, found=False, required=required, install_hint=install_hint, category=category)
148
142
  version = _get_version(name, path)
149
143
  return DependencyStatus(
150
144
  name=name,
@@ -162,6 +156,50 @@ def _check_dependency(
162
156
  # ---------------------------------------------------------------------------
163
157
 
164
158
 
159
+ def _check_editable_install() -> DependencyStatus:
160
+ """Report stale editable sources for this interpreter without repairing them."""
161
+ status = DependencyStatus(name="editable-install", found=True, category="Warning")
162
+ status.install_hint = "pip install -e <current-worktree-root>"
163
+ try:
164
+ cwd = Path.cwd().resolve()
165
+ root = next((p for p in (cwd, *cwd.parents) if (p / "pyproject.toml").is_file()), None)
166
+ root_path = ("<current-worktree-root>", str(root))[root is not None]
167
+ powershell_path = "'" + root_path.replace("'", "''") + "'"
168
+ quote = (shlex.quote(root_path), powershell_path)[sys.platform == "win32"]
169
+ status.install_hint = (f"pip install -e {root_path}", f"pip install -e {quote}")[root is not None]
170
+ raw = (installed_distribution := distribution("agentic-devtools")).read_text("direct_url.json")
171
+ if raw is None: # PathDistribution.read_text suppresses PermissionError by returning None.
172
+ try:
173
+ raw = getattr(installed_distribution, "_path").joinpath("direct_url.json").read_text(encoding="utf-8")
174
+ except AttributeError, FileNotFoundError, KeyError, NotADirectoryError:
175
+ return status
176
+ data = json.loads(raw)
177
+ if not isinstance(data, dict):
178
+ raise ValueError("Invalid direct_url.json object")
179
+ info = data.get("dir_info", {})
180
+ if not isinstance(info, dict) or not isinstance(info.get("editable", False), bool):
181
+ raise ValueError("Invalid editable metadata")
182
+ if not info.get("editable", False):
183
+ return status
184
+ url = data.get("url")
185
+ if not isinstance(url, str):
186
+ raise ValueError("Missing editable source URL")
187
+ parsed = urlsplit(url)
188
+ source = Path(url2pathname(url, require_scheme=True))
189
+ if parsed.query or parsed.fragment or not source.is_absolute():
190
+ raise ValueError("Invalid editable source URL")
191
+ source = source.resolve()
192
+ status.path = str(source)
193
+ status.found = source.is_dir() and (
194
+ root is None or os.path.normcase(str(source)) == os.path.normcase(str(root))
195
+ )
196
+ except PackageNotFoundError:
197
+ return status
198
+ except OSError, TypeError, ValueError, UnicodeError, RuntimeError:
199
+ status.found = False
200
+ return status
201
+
202
+
165
203
  def _check_path_profile() -> DependencyStatus:
166
204
  """Check whether ``~/.agdt/bin`` is present as a PATH component.
167
205
 
@@ -281,12 +319,7 @@ def _check_git_hooks_config() -> DependencyStatus:
281
319
  category=_GIT_HOOKS_OPTIONAL_CATEGORY,
282
320
  )
283
321
  except subprocess.TimeoutExpired:
284
- return DependencyStatus(
285
- name="git-hooks",
286
- found=False,
287
- required=False,
288
- category=_GIT_HOOKS_OPTIONAL_CATEGORY,
289
- )
322
+ return DependencyStatus(name="git-hooks", found=False, required=False, category=_GIT_HOOKS_OPTIONAL_CATEGORY)
290
323
 
291
324
  if in_repo.returncode != 0:
292
325
  # Not in a git repo — non-blocking
@@ -308,22 +341,12 @@ def _check_git_hooks_config() -> DependencyStatus:
308
341
  )
309
342
  except FileNotFoundError, subprocess.TimeoutExpired:
310
343
  # git disappeared or timed out — treat as non-blocking (same as git-missing)
311
- return DependencyStatus(
312
- name="git-hooks",
313
- found=False,
314
- required=False,
315
- category=_GIT_HOOKS_OPTIONAL_CATEGORY,
316
- )
344
+ return DependencyStatus(name="git-hooks", found=False, required=False, category=_GIT_HOOKS_OPTIONAL_CATEGORY)
317
345
 
318
346
  if toplevel.returncode != 0:
319
347
  # show-toplevel failed (e.g. detached HEAD, bare repo, or unexpected error)
320
348
  # — cannot validate .githooks dir; treat as non-blocking
321
- return DependencyStatus(
322
- name="git-hooks",
323
- found=False,
324
- required=False,
325
- category=_GIT_HOOKS_OPTIONAL_CATEGORY,
326
- )
349
+ return DependencyStatus(name="git-hooks", found=False, required=False, category=_GIT_HOOKS_OPTIONAL_CATEGORY)
327
350
 
328
351
  repo_root = Path(toplevel.stdout.strip())
329
352
 
@@ -337,12 +360,7 @@ def _check_git_hooks_config() -> DependencyStatus:
337
360
  )
338
361
  except FileNotFoundError, subprocess.TimeoutExpired:
339
362
  # git disappeared or timed out — treat as non-blocking
340
- return DependencyStatus(
341
- name="git-hooks",
342
- found=False,
343
- required=False,
344
- category=_GIT_HOOKS_OPTIONAL_CATEGORY,
345
- )
363
+ return DependencyStatus(name="git-hooks", found=False, required=False, category=_GIT_HOOKS_OPTIONAL_CATEGORY)
346
364
 
347
365
  hooks_path_set = config_result.returncode == 0
348
366
  current = config_result.stdout.strip() if hooks_path_set else ""
@@ -395,40 +413,21 @@ def check_all_dependencies() -> list[DependencyStatus]:
395
413
  A list of :class:`DependencyStatus` objects, one per tool.
396
414
  """
397
415
  results = [
398
- _check_dependency(
399
- "copilot",
400
- required=False,
401
- install_hint="run: agdt-setup-copilot-cli",
402
- category="Recommended",
403
- ),
404
- _check_dependency(
405
- "gh",
406
- required=False,
407
- install_hint="run: agdt-setup-gh-cli (or https://cli.github.com/)",
408
- category="Recommended",
409
- ),
410
- _check_dependency(
411
- "git",
412
- required=True,
413
- install_hint="https://git-scm.com/downloads",
414
- category="Required",
415
- ),
416
- _check_dependency(
417
- "az",
418
- required=False,
419
- install_hint="https://docs.microsoft.com/cli/azure/install-azure-cli",
420
- category="Optional — needed for Azure DevOps",
421
- ),
422
- _check_dependency(
423
- "code",
424
- required=False,
425
- install_hint="https://code.visualstudio.com/",
426
- category="Optional — needed for VS Code integration",
427
- ),
416
+ _check_dependency(name, required=required, install_hint=hint, category=category)
417
+ for name, required, hint, category in (
418
+ ("copilot", False, "run: agdt-setup-copilot-cli", "Recommended"),
419
+ ("gh", False, "run: agdt-setup-gh-cli (or https://cli.github.com/)", "Recommended"),
420
+ ("git", True, "https://git-scm.com/downloads", "Required"),
421
+ (
422
+ "az",
423
+ False,
424
+ "https://docs.microsoft.com/cli/azure/install-azure-cli",
425
+ "Optional — needed for Azure DevOps",
426
+ ),
427
+ ("code", False, "https://code.visualstudio.com/", "Optional — needed for VS Code integration"),
428
+ )
428
429
  ]
429
- results.append(_check_path_profile())
430
- results.append(_check_git_hooks_config())
431
- return results
430
+ return [*results, _check_path_profile(), _check_git_hooks_config(), _check_editable_install()]
432
431
 
433
432
 
434
433
  def print_dependency_report(statuses: list[DependencyStatus]) -> None:
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import time
15
15
  from collections.abc import Callable
16
- from dataclasses import dataclass, field
16
+ from dataclasses import asdict, dataclass, field
17
17
  from dataclasses import replace as _replace
18
18
  from typing import Any
19
19
 
@@ -250,7 +250,7 @@ def run_doctor(
250
250
  classified_problems: list[tuple[ErrorClass, DependencyStatus]] = []
251
251
  repair_outcomes: list[RepairOutcome] = []
252
252
  phases: list[PhaseResult] = []
253
- extra_details: dict[str, Any] = {}
253
+ extra_details: dict[str, Any] = {"dependencies": [asdict(status) for status in statuses]}
254
254
 
255
255
  # ── Phase 1: check ────────────────────────────────────────────────────────
256
256
  check_start = time.monotonic()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-devtools
3
- Version: 0.2.596
3
+ Version: 0.2.598
4
4
  Summary: Agentic devtools integrate Jira, DevOps & more
5
5
  Author: ayaiayorg
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  agentic_devtools/__init__.py,sha256=gvLzsZ6L3Abr4sPHr4OJ_rdZDD8cFzXacaV0LGs8dVc,539
2
- agentic_devtools/_version.py,sha256=AvrHmM1z84QxURku5GjpXs4ePlchTMmZCz-bqWtaQkM,524
2
+ agentic_devtools/_version.py,sha256=J38w9gRM5zrffQfl71XdldHwa7TCV6jmj0Fcjq-GZc8,524
3
3
  agentic_devtools/agdt_gitignore.py,sha256=vVVqTjBZ_ijn6U3Lnh_JGrARLschYVN-fFal9jDk0Ak,2694
4
4
  agentic_devtools/background_tasks.py,sha256=sFgmqOnCdCjclABJ8V__yDwBXDyi45b1Z-MVv3G2zB8,17664
5
5
  agentic_devtools/config.py,sha256=DEVxTVZhsQbVQwO9qdB_1h19aBpjMzF6xJY_ePYLGLs,15629
@@ -290,7 +290,8 @@ agentic_devtools/cli/ci/suppressed_reaper.py,sha256=hitRKUr3BhxB5fCJvEDvxMU0_9c6
290
290
  agentic_devtools/cli/ci/suppressed_reaper_command.py,sha256=Dl-T5haMCyaAo1fnCnLoKsJw4aRwqwLiB2v1o8XWx_8,2247
291
291
  agentic_devtools/cli/ci/watchdog_command.py,sha256=8I_emQPPU1wx37DcJUgNz-PRl1zsKBv88pWHIFGXw3s,54494
292
292
  agentic_devtools/cli/ci/windows_fixture_bytes.py,sha256=19rXUSdokwWel5N3ngOkdelQVv-dGjAWyZCaVHQNpWI,6188
293
- agentic_devtools/cli/ci/windows_specific.py,sha256=e6ZNu6rlEdx9X7fmUPB64zH_ANaR-_T9aqe50AHNNLE,23538
293
+ agentic_devtools/cli/ci/windows_specific.py,sha256=7xzmQp2A0wAGe3H-qmfWwqESt9E-GKA4Fu_6rAIIjms,23159
294
+ agentic_devtools/cli/ci/windows_specific_cli.py,sha256=g0CtGsvdN-RW4FdHO_l86KPosC532npXG3nrBy38CRM,15653
294
295
  agentic_devtools/cli/ci/evaluator/__init__.py,sha256=59IKUIYDwZQCbLwBoMgwlr_kHYkdWOXIONnRlICnySk,1079
295
296
  agentic_devtools/cli/ci/evaluator/actions.py,sha256=ofZpNDtBtLMH_Ka4h3JKEnhxaR1nrDGIrpzhHB1vW34,30274
296
297
  agentic_devtools/cli/ci/evaluator/classifier.py,sha256=5o_LguIbS8jMScsIs-HPNObzeMtHdcd4X1IPOP8rrSM,5867
@@ -555,8 +556,8 @@ agentic_devtools/cli/setup/commit_template_setup.py,sha256=3xojqN7o8uF12PfnSdtaG
555
556
  agentic_devtools/cli/setup/copilot_cli_installer.py,sha256=LoBWSfuHlyzrDMyUb8h5gOe77JLW5XSyaif_XE7PLXY,10040
556
557
  agentic_devtools/cli/setup/copilot_settings.py,sha256=hLpU4MA3dHVa0Pq19bzoSsVbF8zPaTs-He4T_uDgYpM,9854
557
558
  agentic_devtools/cli/setup/decision_log.py,sha256=KAqxWyPJLcRg2bFT2-pbx6faobo9dZktMlyuE9Qxaz4,12396
558
- agentic_devtools/cli/setup/dependency_checker.py,sha256=F8XZEAt0TtOwj50IpHxUDU7QB-9FUiZUdy-BFUY54FE,15374
559
- agentic_devtools/cli/setup/doctor.py,sha256=cDCNEaHifmJtx6qHvnqa3qRe9FuF7zb-zHxlGGTw9oE,18391
559
+ agentic_devtools/cli/setup/dependency_checker.py,sha256=ytDKcq6QepYAhGt2obHriOuOrCDgO8YzsGqt2kDJj8E,17272
560
+ agentic_devtools/cli/setup/doctor.py,sha256=z1vEDQKI4jSMu2pYLObAxiZv4c5-5SnTNWCf4ipsVJc,18454
560
561
  agentic_devtools/cli/setup/doctor_repair.py,sha256=JmlQZi6dIqke7Rzq19EfIgkbtYbE-a0DdqLYg1iyP4A,6230
561
562
  agentic_devtools/cli/setup/doctor_repairs.py,sha256=_iuruXUB9vrrhP2YcRz-dZfpIKEqWoyQ-WxtQ11YD9M,9809
562
563
  agentic_devtools/cli/setup/exit_codes.py,sha256=56ej8g1n8K5cmsuyE0eEwoITqkJLWONRsNI6DfunuVQ,2553
@@ -1305,8 +1306,8 @@ agentic_devtools/_bundled_skills/skills/speckit-plan/SKILL.md,sha256=qYLpzgLCIZ6
1305
1306
  agentic_devtools/_bundled_skills/skills/speckit-specify/SKILL.md,sha256=HbHRbdphMqbtNw6fL6NcYCj6sSFmUyk2S4U0bZ9p_l4,13681
1306
1307
  agentic_devtools/_bundled_skills/skills/write-github-commit-message/SKILL.md,sha256=Ep1lR8NDN117Iytj172VZgqazFiLpJ56Z7RXgaTnxR8,2626
1307
1308
  agentic_devtools/_bundled_skills/skills/write-github-commit-message/commit-types.md,sha256=bTy_t68ZD6R87rFIzq9wybTu8Wudoahb7nB9IWC4bFo,1839
1308
- agentic_devtools-0.2.596.dist-info/METADATA,sha256=LpdqiT1tbaT8eB4vtvWOdBDdCX5ZY7TdRs7odTvahHs,35589
1309
- agentic_devtools-0.2.596.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1310
- agentic_devtools-0.2.596.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1311
- agentic_devtools-0.2.596.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1312
- agentic_devtools-0.2.596.dist-info/RECORD,,
1309
+ agentic_devtools-0.2.598.dist-info/METADATA,sha256=NQlxxZMvekaRdXpTDU1tKYAqPvYURKqbkk3qUDhZDXE,35589
1310
+ agentic_devtools-0.2.598.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
1311
+ agentic_devtools-0.2.598.dist-info/entry_points.txt,sha256=kL35_Y5OHI6XpPneeBTcX82ORjjfx2VIK6XfmKHgNm4,13630
1312
+ agentic_devtools-0.2.598.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
1313
+ agentic_devtools-0.2.598.dist-info/RECORD,,