graphite-code 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1586 @@
1
+ """Consent and policy orchestration for optional project-local TypeScript.
2
+
3
+ This module's detector is deliberately pure and filesystem-driven. Process
4
+ execution, consent, validation, installation, and verification belong to the
5
+ activation orchestration layer.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import os
13
+ import re
14
+ import stat
15
+ import sys
16
+ import tempfile
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from enum import StrEnum
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ._cleanup_worker import _windows_directory_identity
26
+ from .config import Config
27
+ from .dependency_install import (
28
+ ACTIVATION_MAX_FILES,
29
+ MAX_CONTROL_FILE_BYTES,
30
+ FileSnapshot,
31
+ Manager,
32
+ Runner,
33
+ StepResult,
34
+ TRUSTED_REGISTRY,
35
+ TrustedFile,
36
+ _trusted_identity_launcher,
37
+ adapter_for,
38
+ command_for,
39
+ control_files_use_trusted_sources,
40
+ probe_local_typescript,
41
+ revalidate_trusted_file,
42
+ resolve_trusted_executable,
43
+ resolve_trusted_file,
44
+ resolve_windows_npm_prefix,
45
+ run_install,
46
+ run_manager_version,
47
+ run_validator,
48
+ snapshot_control_file,
49
+ )
50
+ from .probe_process import (
51
+ ProbeProcessError,
52
+ run_bounded_process,
53
+ sanitized_probe_environment,
54
+ )
55
+ from .ingest import SKIP_DIRS
56
+ from .probe_workspace import (
57
+ _close_raw_handle,
58
+ _open_directory_handle,
59
+ _windows_handle_final_path,
60
+ )
61
+
62
+
63
+ class ActivationOutcome(StrEnum):
64
+ INSTALLED = "installed"
65
+ ALREADY_AVAILABLE = "already_available"
66
+ NOT_APPLICABLE = "not_applicable"
67
+ DECLINED = "declined"
68
+ GUIDANCE_ONLY = "guidance_only"
69
+ VALIDATION_FAILED = "validation_failed"
70
+ INSTALLATION_FAILED = "installation_failed"
71
+ VERIFICATION_FAILED = "verification_failed"
72
+
73
+
74
+ FATAL_OUTCOMES = frozenset(
75
+ {
76
+ ActivationOutcome.VALIDATION_FAILED,
77
+ ActivationOutcome.INSTALLATION_FAILED,
78
+ ActivationOutcome.VERIFICATION_FAILED,
79
+ }
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ActivationResult:
85
+ outcome: ActivationOutcome
86
+ manager: Manager | None
87
+ reason: str
88
+ manifest: str | None = None
89
+ lockfile: str | None = None
90
+ changed_files: tuple[str, ...] = ()
91
+ attempted: bool = False
92
+
93
+ @property
94
+ def fatal(self) -> bool:
95
+ return self.outcome in FATAL_OUTCOMES
96
+
97
+ def to_dict(self) -> dict[str, object]:
98
+ return {
99
+ "outcome": self.outcome.value,
100
+ "manager": self.manager.value if self.manager else None,
101
+ "reason": self.reason,
102
+ "manifest": self.manifest,
103
+ "lockfile": self.lockfile,
104
+ "changed_files": list(self.changed_files),
105
+ "attempted": self.attempted,
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ActivationDetection:
111
+ result: ActivationResult | None
112
+ manager: Manager | None
113
+ manifest: str | None
114
+ lockfile: str | None
115
+ manifest_snapshot: FileSnapshot | None
116
+ lockfile_snapshot: FileSnapshot | None
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ActivationRequest:
121
+ root: Path
122
+ cfg: Config
123
+ stdin_is_tty: bool
124
+ stdout_is_tty: bool
125
+ assume_yes: bool
126
+ json_mode: bool
127
+ timeout_seconds: float = 120.0
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class _TemporaryDirectoryLease:
132
+ name: str
133
+ identity: tuple[int, int]
134
+
135
+ def __fspath__(self) -> str:
136
+ return self.name
137
+
138
+
139
+ # Fixed worker protocol disposition; mirrored by
140
+ # ``_cleanup_worker.EXIT_POSIX_ROOT_RETAINED`` without importing package code
141
+ # into the isolated worker process.
142
+ _POSIX_CLEANUP_ROOT_RETAINED_EXIT = 75
143
+
144
+
145
+ def _new_temporary_directory() -> _TemporaryDirectoryLease:
146
+ temporary_parent = Path(tempfile.gettempdir()).resolve(strict=True)
147
+ path = Path(
148
+ tempfile.mkdtemp(prefix="graphite-typescript-", dir=temporary_parent)
149
+ ).absolute()
150
+ details = path.lstat()
151
+ if not stat.S_ISDIR(details.st_mode) or _is_reparse(details):
152
+ raise OSError("temporary_directory_invalid")
153
+ return _TemporaryDirectoryLease(
154
+ str(path), _temporary_directory_identity(path, details)
155
+ )
156
+
157
+
158
+ def _temporary_directory_identity(
159
+ path: Path, details: os.stat_result | None = None
160
+ ) -> tuple[int, int]:
161
+ if os.name == "nt":
162
+ return _windows_directory_identity(path)
163
+ selected = details if details is not None else path.lstat()
164
+ return selected.st_dev, selected.st_ino
165
+
166
+
167
+ def _rename_no_replace(source: Path, destination: Path) -> bool:
168
+ """Atomically rename one path without clobbering an existing recovery path."""
169
+ try:
170
+ if os.name == "nt":
171
+ os.rename(source, destination)
172
+ return True
173
+ import ctypes
174
+
175
+ libc = ctypes.CDLL(None, use_errno=True)
176
+ source_bytes = os.fsencode(source)
177
+ destination_bytes = os.fsencode(destination)
178
+ renameat2 = getattr(libc, "renameat2", None)
179
+ if renameat2 is not None:
180
+ renameat2.argtypes = [
181
+ ctypes.c_int,
182
+ ctypes.c_char_p,
183
+ ctypes.c_int,
184
+ ctypes.c_char_p,
185
+ ctypes.c_uint,
186
+ ]
187
+ renameat2.restype = ctypes.c_int
188
+ return renameat2(-100, source_bytes, -100, destination_bytes, 1) == 0
189
+ renamex_np = getattr(libc, "renamex_np", None)
190
+ if renamex_np is not None:
191
+ renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
192
+ renamex_np.restype = ctypes.c_int
193
+ return renamex_np(source_bytes, destination_bytes, 4) == 0
194
+ except (OSError, TypeError, ValueError):
195
+ return False
196
+ return False
197
+
198
+
199
+ def _trusted_running_interpreter(root: Path) -> TrustedFile | None:
200
+ """Pin the running interpreter, preferring the name it was invoked as.
201
+
202
+ A POSIX virtual environment's ``bin/python`` is a symlink, and Python
203
+ locates a virtual environment from the executable it was *invoked as* -- so
204
+ canonicalising that name first launches the base installation under a
205
+ different ``sys.prefix``, which is not the runtime that decided to run this
206
+ worker. Validate the symlink route instead, and keep the given name.
207
+
208
+ Three steps, narrowing deliberately:
209
+
210
+ 1. The full symlink route, path-trusted end to end. Best evidence, so first.
211
+ 2. Failing that, the target's identity and content, keeping the name -- see
212
+ `_trusted_identity_launcher`. A hosted CI runner ships its whole Python
213
+ toolchain mode 0777, so step 1 refuses all seven components there while
214
+ `_trusted_file` accepts the very same binary; without this step that gap
215
+ silently became "launch a different interpreter".
216
+ 3. Only then the canonical target, which drops the name. Reached when the
217
+ launcher is one the selected repository controls -- a virtual environment
218
+ inside it, say -- where refusing the name is the entire point.
219
+
220
+ A previous version of this docstring claimed the canonical fallback was "no
221
+ weaker than what this did". That was wrong, and measuring is what showed it:
222
+ `_trusted_posix_launcher` enforces path trust while `_trusted_file` enforces
223
+ identity and content, so falling from the first to the second is a change of
224
+ threat model, not a narrowing of one. Step 2 exists so that trade is made
225
+ once, on purpose, for the interpreter already executing this process.
226
+ """
227
+ executable = Path(sys.executable)
228
+ reference = resolve_trusted_file(executable, root, executable=True, follow_launcher=True)
229
+ if reference is not None:
230
+ return reference
231
+ reference = _trusted_identity_launcher(executable, root)
232
+ if reference is not None:
233
+ return reference
234
+ try:
235
+ canonical = executable.resolve(strict=True)
236
+ except (OSError, RuntimeError):
237
+ return None
238
+ return resolve_trusted_file(canonical, root, executable=True)
239
+
240
+
241
+ def _cleanup_isolated_home(
242
+ lease: _TemporaryDirectoryLease,
243
+ root: Path,
244
+ timeout: float,
245
+ ) -> StepResult:
246
+ if not math.isfinite(timeout) or timeout <= 0:
247
+ return StepResult(False, "cleanup_timeout")
248
+ validated_lease = _validated_isolated_lease(lease, root)
249
+ if validated_lease is None:
250
+ return StepResult(False, "cleanup_failed")
251
+ isolated_home = Path(validated_lease.name)
252
+ cleanup_target = isolated_home.with_name(
253
+ f".{isolated_home.name}.graphite-cleanup-recovery-"
254
+ f"{lease.identity[0]:x}-{lease.identity[1]:x}"
255
+ )
256
+ if not _rename_no_replace(isolated_home, cleanup_target):
257
+ return StepResult(False, "cleanup_failed")
258
+
259
+ def fail_after_quarantine(reason: str = "cleanup_failed") -> StepResult:
260
+ if _path_is_lexically_present(cleanup_target):
261
+ _rename_no_replace(cleanup_target, isolated_home)
262
+ return StepResult(False, reason)
263
+
264
+ try:
265
+ details = cleanup_target.lstat()
266
+ if (
267
+ _temporary_directory_identity(cleanup_target, details) != lease.identity
268
+ or not stat.S_ISDIR(details.st_mode)
269
+ or stat.S_ISLNK(details.st_mode)
270
+ or _is_reparse(details)
271
+ or cleanup_target.resolve(strict=True) != cleanup_target
272
+ ):
273
+ return fail_after_quarantine()
274
+ except (OSError, RuntimeError, ValueError):
275
+ return fail_after_quarantine()
276
+ worker = Path(__file__).with_name("_cleanup_worker.py")
277
+ worker_reference = resolve_trusted_file(worker, root, executable=False)
278
+ python_reference = _trusted_running_interpreter(root)
279
+ if worker_reference is None or python_reference is None:
280
+ return fail_after_quarantine()
281
+ if not revalidate_trusted_file(
282
+ worker_reference, root, executable=False
283
+ ) or not revalidate_trusted_file(python_reference, root, executable=True):
284
+ return fail_after_quarantine()
285
+ try:
286
+ result = run_bounded_process(
287
+ [
288
+ str(python_reference.command_path),
289
+ "-I",
290
+ str(worker_reference.path),
291
+ str(cleanup_target),
292
+ str(lease.identity[0]),
293
+ str(lease.identity[1]),
294
+ ],
295
+ cwd=cleanup_target.parent,
296
+ stdin=None,
297
+ timeout_seconds=timeout,
298
+ max_output_bytes=4096,
299
+ check=False,
300
+ environment=sanitized_probe_environment(),
301
+ )
302
+ except ProbeProcessError as error:
303
+ reason = "cleanup_timeout" if error.code == "timeout" else "cleanup_failed"
304
+ return fail_after_quarantine(reason)
305
+ except Exception:
306
+ return fail_after_quarantine()
307
+ # POSIX has no primitive that unlinks an open directory by descriptor.
308
+ # Exit 75 means the trusted worker emptied the held Graphite-created lease
309
+ # but intentionally retained its root name, which we leave untouched for
310
+ # OS temporary-directory reclamation. In particular, do not inspect and
311
+ # then remove that name: a same-UID actor could have replaced it meanwhile.
312
+ if (
313
+ os.name != "nt"
314
+ and result.returncode == _POSIX_CLEANUP_ROOT_RETAINED_EXIT
315
+ ):
316
+ return StepResult(True, "cleanup_root_retained")
317
+ if result.returncode != 0 or _path_is_lexically_present(cleanup_target):
318
+ return fail_after_quarantine()
319
+ return StepResult(True, "cleaned")
320
+
321
+
322
+ @dataclass
323
+ class ActivationDependencies:
324
+ prompt: Callable[[str], str] = input
325
+ environ: Mapping[str, str] = field(default_factory=lambda: os.environ)
326
+ monotonic: Callable[[], float] = time.monotonic
327
+ runner: Runner = run_bounded_process
328
+ temporary_directory: Callable[[], Any] = _new_temporary_directory
329
+ cleanup: Callable[[_TemporaryDirectoryLease, Path, float], StepResult] = (
330
+ _cleanup_isolated_home
331
+ )
332
+
333
+
334
+ _ACTIVATION_LOCKS_GUARD = threading.Lock()
335
+ _ACTIVATION_LOCKS: dict[str, tuple[threading.Lock, int]] = {}
336
+ _ACTIVE_CLEANUP_ROOTS: set[str] = set()
337
+ _CLEANUP_MAX_SECONDS = 1.0
338
+
339
+
340
+ def _acquire_activation_lock(root: Path) -> tuple[str, threading.Lock] | None:
341
+ key = os.path.normcase(str(root))
342
+ with _ACTIVATION_LOCKS_GUARD:
343
+ if key in _ACTIVE_CLEANUP_ROOTS:
344
+ return None
345
+ lock, users = _ACTIVATION_LOCKS.get(key, (threading.Lock(), 0))
346
+ _ACTIVATION_LOCKS[key] = (lock, users + 1)
347
+ if lock.acquire(blocking=False):
348
+ return key, lock
349
+ current_lock, current_users = _ACTIVATION_LOCKS[key]
350
+ if current_users == 1:
351
+ del _ACTIVATION_LOCKS[key]
352
+ else:
353
+ _ACTIVATION_LOCKS[key] = (current_lock, current_users - 1)
354
+ return None
355
+
356
+
357
+ def _release_activation_lock(key: str, lock: threading.Lock) -> None:
358
+ lock.release()
359
+ with _ACTIVATION_LOCKS_GUARD:
360
+ current_lock, users = _ACTIVATION_LOCKS[key]
361
+ if users == 1:
362
+ del _ACTIVATION_LOCKS[key]
363
+ else:
364
+ _ACTIVATION_LOCKS[key] = (current_lock, users - 1)
365
+
366
+
367
+ class _DeadlineExpired(Exception):
368
+ pass
369
+
370
+
371
+ class _DependencyFailure(Exception):
372
+ pass
373
+
374
+
375
+ @dataclass(frozen=True)
376
+ class _Deadline:
377
+ expires_at: float
378
+ monotonic: Callable[[], float]
379
+
380
+ def remaining(self) -> float:
381
+ try:
382
+ remaining = self.expires_at - self.monotonic()
383
+ if not math.isfinite(remaining):
384
+ raise ValueError("clock_invalid")
385
+ except Exception:
386
+ raise _DependencyFailure from None
387
+ if remaining <= 0:
388
+ raise _DeadlineExpired
389
+ return remaining
390
+
391
+
392
+ def _mark_cleanup_active(root: Path) -> None:
393
+ key = os.path.normcase(str(root))
394
+ with _ACTIVATION_LOCKS_GUARD:
395
+ _ACTIVE_CLEANUP_ROOTS.add(key)
396
+
397
+
398
+ def _clear_cleanup_active(root: Path) -> None:
399
+ key = os.path.normcase(str(root))
400
+ with _ACTIVATION_LOCKS_GUARD:
401
+ _ACTIVE_CLEANUP_ROOTS.discard(key)
402
+
403
+
404
+ def _bounded_temporary_cleanup(
405
+ lease: _TemporaryDirectoryLease,
406
+ deadline: _Deadline,
407
+ root: Path,
408
+ cleanup: Callable[[_TemporaryDirectoryLease, Path, float], StepResult],
409
+ ) -> tuple[str, bool]:
410
+ """Run terminable cleanup while lock acquisition observes its ownership."""
411
+ expired_before_cleanup = False
412
+ dependency_failed = False
413
+ try:
414
+ remaining = deadline.remaining()
415
+ except _DeadlineExpired:
416
+ remaining = _CLEANUP_MAX_SECONDS
417
+ expired_before_cleanup = True
418
+ except _DependencyFailure:
419
+ remaining = _CLEANUP_MAX_SECONDS
420
+ dependency_failed = True
421
+ cleanup_timeout = min(_CLEANUP_MAX_SECONDS, max(0.001, remaining))
422
+ _mark_cleanup_active(root)
423
+ try:
424
+ cleanup_result = cleanup(lease, root, cleanup_timeout)
425
+ except Exception:
426
+ cleanup_result = StepResult(False, "cleanup_failed")
427
+ finally:
428
+ _clear_cleanup_active(root)
429
+ if dependency_failed:
430
+ return "dependency_failed", expired_before_cleanup
431
+ if not isinstance(cleanup_result, StepResult):
432
+ return "cleanup_failed", expired_before_cleanup
433
+ if not cleanup_result.ok:
434
+ reason = (
435
+ cleanup_result.reason
436
+ if cleanup_result.reason in {"cleanup_failed", "cleanup_timeout"}
437
+ else "cleanup_failed"
438
+ )
439
+ return reason, expired_before_cleanup
440
+ try:
441
+ deadline.remaining()
442
+ except _DeadlineExpired:
443
+ return "cleanup_timeout", expired_before_cleanup
444
+ except _DependencyFailure:
445
+ return "dependency_failed", expired_before_cleanup
446
+ return "cleaned", expired_before_cleanup
447
+
448
+
449
+ _PACKAGE_MANAGER_RE = re.compile(r"(npm|pnpm|yarn|bun)@(\S+)")
450
+ _STABLE_STAT_FIELDS = ("st_dev", "st_ino", "st_size", "st_mtime_ns")
451
+ _EVIDENCE_SCAN_SECONDS = 1.0
452
+ _EVIDENCE_MAX_DEPTH = 128
453
+ _SUPPORTED_LOCKFILES = tuple(
454
+ (lockfile, manager)
455
+ for manager in Manager
456
+ for lockfile in adapter_for(manager).lockfiles
457
+ )
458
+
459
+
460
+ class _EvidenceScanOutcome(StrEnum):
461
+ FOUND = "found"
462
+ ABSENT = "absent"
463
+ LIMITED = "limited"
464
+ UNSAFE = "unsafe"
465
+
466
+
467
+ @dataclass(frozen=True)
468
+ class _ScanDirectory:
469
+ path: Path
470
+ handle: int
471
+ identity: tuple[int, int]
472
+
473
+
474
+ @dataclass
475
+ class _EvidenceScanState:
476
+ entry_limit: int
477
+ deadline: float
478
+ visited: int = 0
479
+
480
+
481
+ def _is_reparse(details: os.stat_result) -> bool:
482
+ attributes = getattr(details, "st_file_attributes", 0)
483
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
484
+ return bool(attributes & reparse_flag)
485
+
486
+
487
+ def _is_contained(path: Path, root: Path) -> bool:
488
+ try:
489
+ path.relative_to(root)
490
+ except ValueError:
491
+ return False
492
+ return True
493
+
494
+
495
+ def _path_is_lexically_present(path: Path) -> bool:
496
+ try:
497
+ return os.path.lexists(path)
498
+ except (OSError, ValueError):
499
+ return True
500
+
501
+
502
+ def _root_file_state(
503
+ root: Path,
504
+ relative_path: str,
505
+ *,
506
+ maximum_size: int | None = None,
507
+ ) -> str:
508
+ path = root / relative_path
509
+ if not _path_is_lexically_present(path):
510
+ return "missing"
511
+ try:
512
+ details = path.lstat()
513
+ if stat.S_ISLNK(details.st_mode) or _is_reparse(details):
514
+ return "unsafe"
515
+ if not stat.S_ISREG(details.st_mode):
516
+ return "unsafe"
517
+ if maximum_size is not None and details.st_size > maximum_size:
518
+ return "oversized"
519
+ canonical = path.resolve(strict=True)
520
+ except (OSError, RuntimeError):
521
+ return "unsafe"
522
+ if canonical != path or not _is_contained(canonical, root):
523
+ return "unsafe"
524
+ return "safe"
525
+
526
+
527
+ def _evidence_entry_limit(value: int | None) -> int | None:
528
+ if isinstance(value, bool) or (value is not None and not isinstance(value, int)):
529
+ return None
530
+ if value is not None and value < 0:
531
+ return None
532
+ return min(value or ACTIVATION_MAX_FILES, ACTIVATION_MAX_FILES)
533
+
534
+
535
+ def _configured_exclusions(root: Path, cfg: Config) -> tuple[tuple[str, ...], ...]:
536
+ exclusions: set[tuple[str, ...]] = set()
537
+ for configured in (cfg.output_dir, cfg.cache_dir):
538
+ candidate = configured if configured.is_absolute() else root / configured
539
+ try:
540
+ relative = candidate.resolve(strict=False).relative_to(root)
541
+ except (OSError, RuntimeError, ValueError):
542
+ continue
543
+ if relative != Path("."):
544
+ exclusions.add(tuple(os.path.normcase(part) for part in relative.parts))
545
+ return tuple(sorted(exclusions))
546
+
547
+
548
+ def _is_excluded_path(
549
+ parts: tuple[str, ...],
550
+ exclusions: tuple[tuple[str, ...], ...],
551
+ ) -> bool:
552
+ normalized = tuple(os.path.normcase(part) for part in parts)
553
+ return any(
554
+ len(normalized) >= len(exclusion)
555
+ and normalized[: len(exclusion)] == exclusion
556
+ for exclusion in exclusions
557
+ )
558
+
559
+
560
+ def _windows_identity(raw_identity: tuple[int, int, int]) -> tuple[int, int]:
561
+ return raw_identity[0], (raw_identity[1] << 32) | raw_identity[2]
562
+
563
+
564
+ def _same_windows_path(left: Path, right: Path) -> bool:
565
+ return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
566
+
567
+
568
+ def _open_root_scan_directory(root: Path) -> _ScanDirectory | None:
569
+ handle = -1
570
+ try:
571
+ if os.name == "nt":
572
+ handle, raw_identity = _open_directory_handle(root)
573
+ final_path = _windows_handle_final_path(handle)
574
+ if not _same_windows_path(final_path, root):
575
+ raise OSError("directory_binding_changed")
576
+ return _ScanDirectory(root, handle, _windows_identity(raw_identity))
577
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
578
+ handle = os.open(root, flags)
579
+ os.set_inheritable(handle, False)
580
+ details = os.fstat(handle)
581
+ if not stat.S_ISDIR(details.st_mode):
582
+ raise OSError("directory_invalid")
583
+ return _ScanDirectory(root, handle, (details.st_dev, details.st_ino))
584
+ except (OSError, RuntimeError, ValueError):
585
+ if handle >= 0:
586
+ _close_raw_handle(handle)
587
+ return None
588
+
589
+
590
+ def _open_child_scan_directory(
591
+ parent: _ScanDirectory,
592
+ name: str,
593
+ expected_identity: tuple[int, int],
594
+ canonical_root: Path,
595
+ ) -> _ScanDirectory | None:
596
+ handle = -1
597
+ child_path = parent.path / name
598
+ try:
599
+ if os.name == "nt":
600
+ handle, raw_identity = _open_directory_handle(child_path)
601
+ identity = _windows_identity(raw_identity)
602
+ final_path = _windows_handle_final_path(handle)
603
+ if (
604
+ identity[1] != expected_identity[1]
605
+ or not _is_contained(final_path, canonical_root)
606
+ or not _same_windows_path(final_path, child_path)
607
+ ):
608
+ raise OSError("directory_binding_changed")
609
+ return _ScanDirectory(child_path, handle, identity)
610
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
611
+ handle = os.open(name, flags, dir_fd=parent.handle)
612
+ os.set_inheritable(handle, False)
613
+ details = os.fstat(handle)
614
+ identity = (details.st_dev, details.st_ino)
615
+ if not stat.S_ISDIR(details.st_mode) or identity != expected_identity:
616
+ raise OSError("directory_binding_changed")
617
+ return _ScanDirectory(child_path, handle, identity)
618
+ except (OSError, RuntimeError, ValueError):
619
+ if handle >= 0:
620
+ _close_raw_handle(handle)
621
+ return None
622
+
623
+
624
+ def _scan_open_directory(
625
+ directory: _ScanDirectory,
626
+ root: Path,
627
+ cfg: Config,
628
+ exclusions: tuple[tuple[str, ...], ...],
629
+ state: _EvidenceScanState,
630
+ *,
631
+ depth: int,
632
+ ) -> _EvidenceScanOutcome:
633
+ if time.monotonic() >= state.deadline:
634
+ return _EvidenceScanOutcome.LIMITED
635
+ scan_target: int | Path = directory.path if os.name == "nt" else directory.handle
636
+ try:
637
+ with os.scandir(scan_target) as entries:
638
+ for entry in entries:
639
+ state.visited += 1
640
+ if (
641
+ state.visited > state.entry_limit
642
+ or time.monotonic() >= state.deadline
643
+ ):
644
+ return _EvidenceScanOutcome.LIMITED
645
+ details = (
646
+ os.lstat(directory.path / entry.name)
647
+ if os.name == "nt"
648
+ else entry.stat(follow_symlinks=False)
649
+ )
650
+ if stat.S_ISLNK(details.st_mode) or _is_reparse(details):
651
+ continue
652
+ parts = (*directory.path.relative_to(root).parts, entry.name)
653
+ if stat.S_ISDIR(details.st_mode):
654
+ if (
655
+ entry.name in SKIP_DIRS
656
+ or (not cfg.include_dotfiles and entry.name.startswith("."))
657
+ or _is_excluded_path(parts, exclusions)
658
+ ):
659
+ continue
660
+ if depth >= _EVIDENCE_MAX_DEPTH:
661
+ return _EvidenceScanOutcome.LIMITED
662
+ child = _open_child_scan_directory(
663
+ directory,
664
+ entry.name,
665
+ (details.st_dev, details.st_ino),
666
+ root,
667
+ )
668
+ if child is None:
669
+ return _EvidenceScanOutcome.UNSAFE
670
+ try:
671
+ child_outcome = _scan_open_directory(
672
+ child,
673
+ root,
674
+ cfg,
675
+ exclusions,
676
+ state,
677
+ depth=depth + 1,
678
+ )
679
+ finally:
680
+ _close_raw_handle(child.handle)
681
+ if child_outcome is not _EvidenceScanOutcome.ABSENT:
682
+ return child_outcome
683
+ continue
684
+ if not stat.S_ISREG(details.st_mode):
685
+ continue
686
+ if not cfg.include_dotfiles and entry.name.startswith("."):
687
+ continue
688
+ if Path(entry.name).suffix.lower() in {".ts", ".tsx"}:
689
+ return _EvidenceScanOutcome.FOUND
690
+ except (OSError, RuntimeError, ValueError):
691
+ return _EvidenceScanOutcome.UNSAFE
692
+ return _EvidenceScanOutcome.ABSENT
693
+
694
+
695
+ def _scan_typescript_evidence(
696
+ root: Path,
697
+ cfg: Config,
698
+ entry_limit: int,
699
+ ) -> _EvidenceScanOutcome:
700
+ state = _EvidenceScanState(
701
+ entry_limit=entry_limit,
702
+ deadline=time.monotonic() + _EVIDENCE_SCAN_SECONDS,
703
+ )
704
+ exclusions = _configured_exclusions(root, cfg)
705
+ root_directory = _open_root_scan_directory(root)
706
+ if root_directory is None:
707
+ return _EvidenceScanOutcome.UNSAFE
708
+ try:
709
+ return _scan_open_directory(
710
+ root_directory,
711
+ root,
712
+ cfg,
713
+ exclusions,
714
+ state,
715
+ depth=0,
716
+ )
717
+ finally:
718
+ _close_raw_handle(root_directory.handle)
719
+
720
+
721
+ def _read_stable_control_file(
722
+ root: Path,
723
+ relative_path: str,
724
+ ) -> tuple[bytes, FileSnapshot] | None:
725
+ path = root / relative_path
726
+ descriptor = -1
727
+ close_failed = False
728
+ try:
729
+ before_snapshot = snapshot_control_file(root, relative_path)
730
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
731
+ descriptor = os.open(path, flags)
732
+ before = os.fstat(descriptor)
733
+ if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_CONTROL_FILE_BYTES:
734
+ return None
735
+ chunks: list[bytes] = []
736
+ total = 0
737
+ while total <= MAX_CONTROL_FILE_BYTES:
738
+ chunk = os.read(descriptor, min(64 * 1024, MAX_CONTROL_FILE_BYTES + 1 - total))
739
+ if not chunk:
740
+ break
741
+ chunks.append(chunk)
742
+ total += len(chunk)
743
+ after = os.fstat(descriptor)
744
+ content = b"".join(chunks)
745
+ except (OSError, RuntimeError, ValueError):
746
+ return None
747
+ finally:
748
+ if descriptor >= 0:
749
+ try:
750
+ os.close(descriptor)
751
+ except (OSError, ValueError):
752
+ close_failed = True
753
+ if close_failed:
754
+ return None
755
+ try:
756
+ after_snapshot = snapshot_control_file(root, relative_path)
757
+ except ValueError:
758
+ return None
759
+ if (
760
+ len(content) > MAX_CONTROL_FILE_BYTES
761
+ or len(content) != before.st_size
762
+ or any(getattr(before, field) != getattr(after, field) for field in _STABLE_STAT_FIELDS)
763
+ or before_snapshot != after_snapshot
764
+ or hashlib.sha256(content).hexdigest() != before_snapshot.sha256
765
+ ):
766
+ return None
767
+ return content, before_snapshot
768
+
769
+
770
+ def _reject_json_constant(_value: str) -> None:
771
+ raise ValueError("json_constant_invalid")
772
+
773
+
774
+ def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
775
+ result: dict[str, Any] = {}
776
+ for key, value in pairs:
777
+ if key in result:
778
+ raise ValueError("json_duplicate_key")
779
+ result[key] = value
780
+ return result
781
+
782
+
783
+ def _parse_bounded_json_int(value: str) -> int:
784
+ if len(value.lstrip("-")) > 1024:
785
+ raise ValueError("json_integer_too_long")
786
+ return int(value)
787
+
788
+
789
+ def _parse_finite_json_float(value: str) -> float:
790
+ parsed = float(value)
791
+ if not math.isfinite(parsed):
792
+ raise ValueError("json_float_not_finite")
793
+ return parsed
794
+
795
+
796
+ def _parse_manifest(content: bytes) -> dict[str, Any] | None:
797
+ try:
798
+ parsed = json.loads(
799
+ content,
800
+ parse_constant=_reject_json_constant,
801
+ object_pairs_hook=_reject_duplicate_json_keys,
802
+ parse_int=_parse_bounded_json_int,
803
+ parse_float=_parse_finite_json_float,
804
+ )
805
+ except (UnicodeDecodeError, ValueError, RecursionError):
806
+ return None
807
+ return parsed if isinstance(parsed, dict) else None
808
+
809
+
810
+ def _terminal(
811
+ outcome: ActivationOutcome,
812
+ reason: str,
813
+ *,
814
+ manager: Manager | None = None,
815
+ manifest: str | None = None,
816
+ lockfile: str | None = None,
817
+ ) -> ActivationDetection:
818
+ return ActivationDetection(
819
+ ActivationResult(outcome, manager, reason, manifest, lockfile),
820
+ manager,
821
+ manifest,
822
+ lockfile,
823
+ None,
824
+ None,
825
+ )
826
+
827
+
828
+ def detect_activation(
829
+ root: Path,
830
+ cfg: Config,
831
+ *,
832
+ local_typescript_available: bool,
833
+ ) -> ActivationDetection:
834
+ """Return ephemeral eligibility; revalidate it immediately before mutation."""
835
+ try:
836
+ canonical_root = root.resolve(strict=True)
837
+ if not canonical_root.is_dir():
838
+ raise OSError("repository root is not a directory")
839
+ except (OSError, RuntimeError):
840
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "repository_unsafe")
841
+
842
+ tsconfig_state = _root_file_state(canonical_root, "tsconfig.json")
843
+ if tsconfig_state not in {"missing", "safe"}:
844
+ return _terminal(
845
+ ActivationOutcome.GUIDANCE_ONLY,
846
+ "typescript_configuration_unsafe",
847
+ )
848
+ entry_limit = _evidence_entry_limit(cfg.max_files)
849
+ if entry_limit is None:
850
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "invalid_file_limit")
851
+ evidence = (
852
+ _EvidenceScanOutcome.FOUND
853
+ if tsconfig_state == "safe"
854
+ else _scan_typescript_evidence(canonical_root, cfg, entry_limit)
855
+ )
856
+ if evidence is _EvidenceScanOutcome.LIMITED:
857
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "evidence_scan_limited")
858
+ if evidence is _EvidenceScanOutcome.UNSAFE:
859
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "evidence_collection_failed")
860
+ if evidence is _EvidenceScanOutcome.ABSENT:
861
+ return _terminal(ActivationOutcome.NOT_APPLICABLE, "no_typescript_evidence")
862
+
863
+ if local_typescript_available:
864
+ return _terminal(
865
+ ActivationOutcome.ALREADY_AVAILABLE,
866
+ "local_typescript_available",
867
+ )
868
+
869
+ manifest_name = "package.json"
870
+ manifest_state = _root_file_state(
871
+ canonical_root,
872
+ manifest_name,
873
+ maximum_size=MAX_CONTROL_FILE_BYTES,
874
+ )
875
+ if manifest_state == "missing":
876
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "manifest_missing")
877
+ if manifest_state == "oversized":
878
+ return _terminal(
879
+ ActivationOutcome.GUIDANCE_ONLY,
880
+ "manifest_invalid",
881
+ manifest=manifest_name,
882
+ )
883
+ if manifest_state != "safe":
884
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "manifest_unsafe")
885
+
886
+ stable_manifest = _read_stable_control_file(canonical_root, manifest_name)
887
+ if stable_manifest is None:
888
+ return _terminal(ActivationOutcome.GUIDANCE_ONLY, "manifest_unsafe")
889
+ manifest_bytes, manifest_snapshot = stable_manifest
890
+ manifest_data = _parse_manifest(manifest_bytes)
891
+ if manifest_data is None:
892
+ return _terminal(
893
+ ActivationOutcome.GUIDANCE_ONLY,
894
+ "manifest_invalid",
895
+ manifest=manifest_name,
896
+ )
897
+
898
+ present_lockfiles: list[tuple[str, Manager]] = []
899
+ for lockfile_name, lockfile_manager in _SUPPORTED_LOCKFILES:
900
+ state = _root_file_state(
901
+ canonical_root,
902
+ lockfile_name,
903
+ maximum_size=MAX_CONTROL_FILE_BYTES,
904
+ )
905
+ if state == "missing":
906
+ continue
907
+ if state != "safe":
908
+ return _terminal(
909
+ ActivationOutcome.GUIDANCE_ONLY,
910
+ "lockfile_unsafe",
911
+ manifest=manifest_name,
912
+ )
913
+ present_lockfiles.append((lockfile_name, lockfile_manager))
914
+
915
+ if not present_lockfiles:
916
+ return _terminal(
917
+ ActivationOutcome.GUIDANCE_ONLY,
918
+ "lockfile_missing",
919
+ manifest=manifest_name,
920
+ )
921
+ if len(present_lockfiles) != 1:
922
+ return _terminal(
923
+ ActivationOutcome.GUIDANCE_ONLY,
924
+ "lockfile_ambiguous",
925
+ manifest=manifest_name,
926
+ )
927
+
928
+ lockfile_name, manager = present_lockfiles[0]
929
+ stable_lockfile = _read_stable_control_file(canonical_root, lockfile_name)
930
+ if stable_lockfile is None:
931
+ return _terminal(
932
+ ActivationOutcome.GUIDANCE_ONLY,
933
+ "lockfile_unsafe",
934
+ manager=manager,
935
+ manifest=manifest_name,
936
+ lockfile=lockfile_name,
937
+ )
938
+ lockfile_bytes, lockfile_snapshot = stable_lockfile
939
+
940
+ if "packageManager" in manifest_data:
941
+ package_manager = manifest_data["packageManager"]
942
+ if not isinstance(package_manager, str):
943
+ return _terminal(
944
+ ActivationOutcome.GUIDANCE_ONLY,
945
+ "package_manager_invalid",
946
+ manager=manager,
947
+ manifest=manifest_name,
948
+ lockfile=lockfile_name,
949
+ )
950
+ match = _PACKAGE_MANAGER_RE.fullmatch(package_manager)
951
+ if match is None:
952
+ return _terminal(
953
+ ActivationOutcome.GUIDANCE_ONLY,
954
+ "package_manager_invalid",
955
+ manager=manager,
956
+ manifest=manifest_name,
957
+ lockfile=lockfile_name,
958
+ )
959
+ if Manager(match.group(1)) is not manager:
960
+ return _terminal(
961
+ ActivationOutcome.GUIDANCE_ONLY,
962
+ "package_manager_conflict",
963
+ manager=manager,
964
+ manifest=manifest_name,
965
+ lockfile=lockfile_name,
966
+ )
967
+
968
+ adapter = adapter_for(manager)
969
+ if any(
970
+ _path_is_lexically_present(canonical_root / path)
971
+ for path in adapter.unsafe_root_files
972
+ ):
973
+ return _terminal(
974
+ ActivationOutcome.GUIDANCE_ONLY,
975
+ "manager_configuration_unsafe",
976
+ manager=manager,
977
+ manifest=manifest_name,
978
+ lockfile=lockfile_name,
979
+ )
980
+
981
+ if not control_files_use_trusted_sources(manifest_bytes, lockfile_bytes):
982
+ return _terminal(
983
+ ActivationOutcome.GUIDANCE_ONLY,
984
+ "dependency_source_unsafe",
985
+ manager=manager,
986
+ manifest=manifest_name,
987
+ lockfile=lockfile_name,
988
+ )
989
+
990
+ if not adapter.automatic:
991
+ return _terminal(
992
+ ActivationOutcome.GUIDANCE_ONLY,
993
+ "manager_guidance_only",
994
+ manager=manager,
995
+ manifest=manifest_name,
996
+ lockfile=lockfile_name,
997
+ )
998
+
999
+ return ActivationDetection(
1000
+ None,
1001
+ manager,
1002
+ manifest_name,
1003
+ lockfile_name,
1004
+ manifest_snapshot,
1005
+ lockfile_snapshot,
1006
+ )
1007
+
1008
+
1009
+ def revalidate_activation_detection(
1010
+ root: Path,
1011
+ cfg: Config,
1012
+ expected: ActivationDetection,
1013
+ ) -> bool:
1014
+ """Recheck ephemeral detection immediately before any repository mutation."""
1015
+ try:
1016
+ current = detect_activation(
1017
+ root,
1018
+ cfg,
1019
+ local_typescript_available=False,
1020
+ )
1021
+ return expected.result is None and current.result is None and current == expected
1022
+ except Exception:
1023
+ return False
1024
+
1025
+
1026
+ def _result_for_detection(
1027
+ detection: ActivationDetection,
1028
+ outcome: ActivationOutcome,
1029
+ reason: str,
1030
+ *,
1031
+ attempted: bool = False,
1032
+ changed_files: tuple[str, ...] = (),
1033
+ ) -> ActivationResult:
1034
+ return ActivationResult(
1035
+ outcome,
1036
+ detection.manager,
1037
+ reason,
1038
+ detection.manifest,
1039
+ detection.lockfile,
1040
+ changed_files,
1041
+ attempted,
1042
+ )
1043
+
1044
+
1045
+ def _resolve_manager_command(
1046
+ root: Path,
1047
+ manager: Manager,
1048
+ path_source: str,
1049
+ node: Any,
1050
+ ):
1051
+ if os.name == "nt" and manager is Manager.NPM:
1052
+ return resolve_windows_npm_prefix(root, path_source)
1053
+ executable = resolve_trusted_executable(manager.value, root, path_source)
1054
+ return command_for(executable, node) if executable is not None else None
1055
+
1056
+
1057
+ def _validated_isolated_lease(
1058
+ temporary: Any,
1059
+ root: Path,
1060
+ ) -> _TemporaryDirectoryLease | None:
1061
+ try:
1062
+ candidate = Path(temporary.name)
1063
+ if not candidate.is_absolute():
1064
+ return None
1065
+ lexical = candidate.absolute()
1066
+ details = lexical.lstat()
1067
+ canonical = lexical.resolve(strict=True)
1068
+ canonical_root = root.resolve(strict=True)
1069
+ identity = _temporary_directory_identity(canonical, details)
1070
+ supplied_identity = getattr(temporary, "identity", None)
1071
+ return (
1072
+ _TemporaryDirectoryLease(str(canonical), identity)
1073
+ if (supplied_identity is None or supplied_identity == identity)
1074
+ and canonical == lexical
1075
+ and canonical != canonical_root
1076
+ and not _is_contained(canonical, canonical_root)
1077
+ and not _is_contained(canonical_root, canonical)
1078
+ and stat.S_ISDIR(details.st_mode)
1079
+ and not stat.S_ISLNK(details.st_mode)
1080
+ and not _is_reparse(details)
1081
+ else None
1082
+ )
1083
+ except (AttributeError, OSError, RuntimeError, TypeError, ValueError):
1084
+ return None
1085
+
1086
+
1087
+ def _inspect_post_attempt(
1088
+ root: Path,
1089
+ cfg: Config,
1090
+ detection: ActivationDetection,
1091
+ deadline: _Deadline,
1092
+ ) -> tuple[ActivationResult | None, tuple[str, ...]]:
1093
+ changed_names: set[str] = set()
1094
+ control_unsafe = False
1095
+ for before, relative_path in (
1096
+ (detection.manifest_snapshot, detection.manifest),
1097
+ (detection.lockfile_snapshot, detection.lockfile),
1098
+ ):
1099
+ if relative_path is None:
1100
+ continue
1101
+ try:
1102
+ after = snapshot_control_file(root, relative_path)
1103
+ except ValueError:
1104
+ control_unsafe = True
1105
+ changed_names.add(relative_path)
1106
+ else:
1107
+ if before != after:
1108
+ changed_names.add(relative_path)
1109
+ changed = tuple(sorted(changed_names))
1110
+ try:
1111
+ deadline.remaining()
1112
+ except _DeadlineExpired:
1113
+ return (
1114
+ _result_for_detection(
1115
+ detection,
1116
+ ActivationOutcome.INSTALLATION_FAILED,
1117
+ "operation_timeout",
1118
+ attempted=True,
1119
+ changed_files=changed,
1120
+ ),
1121
+ changed,
1122
+ )
1123
+ except _DependencyFailure:
1124
+ return (
1125
+ _result_for_detection(
1126
+ detection,
1127
+ ActivationOutcome.INSTALLATION_FAILED,
1128
+ "dependency_failed",
1129
+ attempted=True,
1130
+ changed_files=changed,
1131
+ ),
1132
+ changed,
1133
+ )
1134
+ try:
1135
+ current = detect_activation(root, cfg, local_typescript_available=False)
1136
+ except Exception:
1137
+ current = None
1138
+ try:
1139
+ deadline.remaining()
1140
+ except _DeadlineExpired:
1141
+ return (
1142
+ _result_for_detection(
1143
+ detection,
1144
+ ActivationOutcome.INSTALLATION_FAILED,
1145
+ "operation_timeout",
1146
+ attempted=True,
1147
+ changed_files=changed,
1148
+ ),
1149
+ changed,
1150
+ )
1151
+ except _DependencyFailure:
1152
+ return (
1153
+ _result_for_detection(
1154
+ detection,
1155
+ ActivationOutcome.INSTALLATION_FAILED,
1156
+ "dependency_failed",
1157
+ attempted=True,
1158
+ changed_files=changed,
1159
+ ),
1160
+ changed,
1161
+ )
1162
+ if control_unsafe:
1163
+ return (
1164
+ _result_for_detection(
1165
+ detection,
1166
+ ActivationOutcome.INSTALLATION_FAILED,
1167
+ "control_file_unsafe",
1168
+ attempted=True,
1169
+ changed_files=changed,
1170
+ ),
1171
+ changed,
1172
+ )
1173
+ if (
1174
+ current is None
1175
+ or current.result is not None
1176
+ or current.manager is not detection.manager
1177
+ or current.manifest != detection.manifest
1178
+ or current.lockfile != detection.lockfile
1179
+ ):
1180
+ return (
1181
+ _result_for_detection(
1182
+ detection,
1183
+ ActivationOutcome.INSTALLATION_FAILED,
1184
+ "project_state_changed",
1185
+ attempted=True,
1186
+ changed_files=changed,
1187
+ ),
1188
+ changed,
1189
+ )
1190
+ return None, changed
1191
+
1192
+
1193
+ def _verification_result(
1194
+ root: Path,
1195
+ detection: ActivationDetection,
1196
+ node: Any,
1197
+ deadline: _Deadline,
1198
+ runner: Runner,
1199
+ changed: tuple[str, ...],
1200
+ ) -> ActivationResult:
1201
+ verification_timed_out = False
1202
+ try:
1203
+ verified = probe_local_typescript(root, node, deadline.remaining(), runner)
1204
+ deadline.remaining()
1205
+ except _DeadlineExpired:
1206
+ verified = False
1207
+ verification_timed_out = True
1208
+ except _DependencyFailure:
1209
+ return _result_for_detection(
1210
+ detection,
1211
+ ActivationOutcome.VERIFICATION_FAILED,
1212
+ "dependency_failed",
1213
+ attempted=True,
1214
+ changed_files=changed,
1215
+ )
1216
+ if not verified:
1217
+ return _result_for_detection(
1218
+ detection,
1219
+ ActivationOutcome.VERIFICATION_FAILED,
1220
+ "operation_timeout" if verification_timed_out else "typescript_not_available",
1221
+ attempted=True,
1222
+ changed_files=changed,
1223
+ )
1224
+ return _result_for_detection(
1225
+ detection,
1226
+ ActivationOutcome.INSTALLED,
1227
+ "installed",
1228
+ attempted=True,
1229
+ changed_files=changed,
1230
+ )
1231
+
1232
+
1233
+ def _install_with_isolation(
1234
+ root: Path,
1235
+ cfg: Config,
1236
+ detection: ActivationDetection,
1237
+ node: Any,
1238
+ command: Any,
1239
+ deadline: _Deadline,
1240
+ dependencies: ActivationDependencies,
1241
+ ) -> ActivationResult:
1242
+ temporary = None
1243
+ isolated_lease: _TemporaryDirectoryLease | None = None
1244
+ isolated_home: Path | None = None
1245
+ result = _result_for_detection(
1246
+ detection,
1247
+ ActivationOutcome.INSTALLATION_FAILED,
1248
+ "isolation_unavailable",
1249
+ attempted=True,
1250
+ )
1251
+ try:
1252
+ temporary = dependencies.temporary_directory()
1253
+ isolated_lease = _validated_isolated_lease(temporary, root)
1254
+ isolated_home = Path(isolated_lease.name) if isolated_lease is not None else None
1255
+ if isolated_lease is None:
1256
+ result = _result_for_detection(
1257
+ detection,
1258
+ ActivationOutcome.INSTALLATION_FAILED,
1259
+ "isolation_unavailable",
1260
+ attempted=True,
1261
+ )
1262
+ elif not revalidate_activation_detection(root, cfg, detection):
1263
+ result = _result_for_detection(
1264
+ detection,
1265
+ ActivationOutcome.INSTALLATION_FAILED,
1266
+ "project_state_changed",
1267
+ attempted=True,
1268
+ )
1269
+ else:
1270
+ install = run_install(
1271
+ root,
1272
+ command,
1273
+ adapter_for(detection.manager),
1274
+ TRUSTED_REGISTRY,
1275
+ isolated_home,
1276
+ deadline.remaining(),
1277
+ dependencies.runner,
1278
+ )
1279
+ inspection, changed = _inspect_post_attempt(
1280
+ root,
1281
+ cfg,
1282
+ detection,
1283
+ deadline,
1284
+ )
1285
+ if inspection is not None:
1286
+ result = inspection
1287
+ elif not install.ok:
1288
+ result = _result_for_detection(
1289
+ detection,
1290
+ ActivationOutcome.INSTALLATION_FAILED,
1291
+ install.reason,
1292
+ attempted=True,
1293
+ changed_files=changed,
1294
+ )
1295
+ else:
1296
+ result = _verification_result(
1297
+ root,
1298
+ detection,
1299
+ node,
1300
+ deadline,
1301
+ dependencies.runner,
1302
+ changed,
1303
+ )
1304
+ except _DeadlineExpired:
1305
+ result = _result_for_detection(
1306
+ detection,
1307
+ ActivationOutcome.INSTALLATION_FAILED,
1308
+ "operation_timeout",
1309
+ attempted=True,
1310
+ )
1311
+ except _DependencyFailure:
1312
+ result = _result_for_detection(
1313
+ detection,
1314
+ ActivationOutcome.INSTALLATION_FAILED,
1315
+ "dependency_failed",
1316
+ attempted=True,
1317
+ )
1318
+ except Exception:
1319
+ result = _result_for_detection(
1320
+ detection,
1321
+ ActivationOutcome.INSTALLATION_FAILED,
1322
+ "isolation_unavailable",
1323
+ attempted=True,
1324
+ )
1325
+ finally:
1326
+ if isolated_lease is not None:
1327
+ cleanup_status, expired_before_cleanup = _bounded_temporary_cleanup(
1328
+ isolated_lease,
1329
+ deadline,
1330
+ root,
1331
+ dependencies.cleanup,
1332
+ )
1333
+ if cleanup_status == "cleanup_failed":
1334
+ result = _result_for_detection(
1335
+ detection,
1336
+ ActivationOutcome.INSTALLATION_FAILED,
1337
+ "isolation_cleanup_failed",
1338
+ attempted=True,
1339
+ changed_files=result.changed_files,
1340
+ )
1341
+ elif (
1342
+ cleanup_status == "dependency_failed"
1343
+ and result.reason != "dependency_failed"
1344
+ ):
1345
+ result = _result_for_detection(
1346
+ detection,
1347
+ ActivationOutcome.INSTALLATION_FAILED,
1348
+ "dependency_failed",
1349
+ attempted=True,
1350
+ changed_files=result.changed_files,
1351
+ )
1352
+ elif cleanup_status == "cleanup_timeout" and (
1353
+ not expired_before_cleanup
1354
+ or result.outcome is not ActivationOutcome.VERIFICATION_FAILED
1355
+ or result.reason not in {"operation_timeout", "dependency_failed"}
1356
+ ):
1357
+ result = _result_for_detection(
1358
+ detection,
1359
+ ActivationOutcome.INSTALLATION_FAILED,
1360
+ "operation_timeout",
1361
+ attempted=True,
1362
+ changed_files=result.changed_files,
1363
+ )
1364
+ return result
1365
+
1366
+
1367
+ def activate_typescript(
1368
+ request: ActivationRequest,
1369
+ dependencies: ActivationDependencies | None = None,
1370
+ ) -> ActivationResult:
1371
+ """Apply consent and execution policy to optional TypeScript activation."""
1372
+ deps = dependencies or ActivationDependencies()
1373
+ try:
1374
+ canonical_root = request.root.resolve(strict=True)
1375
+ except (OSError, RuntimeError):
1376
+ return ActivationResult(
1377
+ ActivationOutcome.GUIDANCE_ONLY,
1378
+ None,
1379
+ "repository_unsafe",
1380
+ )
1381
+ acquired = _acquire_activation_lock(canonical_root)
1382
+ if acquired is None:
1383
+ return ActivationResult(
1384
+ ActivationOutcome.GUIDANCE_ONLY,
1385
+ None,
1386
+ "activation_in_progress",
1387
+ )
1388
+ key, lock = acquired
1389
+ try:
1390
+ timeout = request.timeout_seconds
1391
+ if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
1392
+ return ActivationResult(
1393
+ ActivationOutcome.GUIDANCE_ONLY,
1394
+ None,
1395
+ "invalid_timeout",
1396
+ )
1397
+ evidence = detect_activation(
1398
+ canonical_root,
1399
+ request.cfg,
1400
+ local_typescript_available=True,
1401
+ )
1402
+ if (
1403
+ evidence.result is not None
1404
+ and evidence.result.outcome is not ActivationOutcome.ALREADY_AVAILABLE
1405
+ ):
1406
+ return evidence.result
1407
+ try:
1408
+ path_source = deps.environ.get("PATH", "")
1409
+ except Exception:
1410
+ return ActivationResult(
1411
+ ActivationOutcome.GUIDANCE_ONLY,
1412
+ None,
1413
+ "dependency_failed",
1414
+ )
1415
+ if not isinstance(path_source, str):
1416
+ path_source = ""
1417
+ node = resolve_trusted_executable("node", canonical_root, path_source)
1418
+ local_available = bool(
1419
+ node is not None
1420
+ and probe_local_typescript(
1421
+ canonical_root,
1422
+ node,
1423
+ min(float(timeout), 10.0),
1424
+ deps.runner,
1425
+ )
1426
+ )
1427
+ if local_available:
1428
+ return evidence.result
1429
+ detection = detect_activation(
1430
+ canonical_root,
1431
+ request.cfg,
1432
+ local_typescript_available=False,
1433
+ )
1434
+ if detection.result is not None:
1435
+ return detection.result
1436
+ if (
1437
+ not request.stdin_is_tty
1438
+ or not request.stdout_is_tty
1439
+ or request.json_mode
1440
+ or request.assume_yes
1441
+ ):
1442
+ return _result_for_detection(
1443
+ detection,
1444
+ ActivationOutcome.GUIDANCE_ONLY,
1445
+ "non_interactive",
1446
+ )
1447
+ if node is None:
1448
+ return _result_for_detection(
1449
+ detection,
1450
+ ActivationOutcome.GUIDANCE_ONLY,
1451
+ "node_unavailable",
1452
+ )
1453
+ command = _resolve_manager_command(
1454
+ canonical_root,
1455
+ detection.manager,
1456
+ path_source,
1457
+ node,
1458
+ )
1459
+ if command is None:
1460
+ return _result_for_detection(
1461
+ detection,
1462
+ ActivationOutcome.GUIDANCE_ONLY,
1463
+ "manager_unavailable",
1464
+ )
1465
+ if os.name == "nt" and detection.manager is Manager.NPM:
1466
+ node = command.references[0]
1467
+ version = run_manager_version(
1468
+ command,
1469
+ canonical_root,
1470
+ min(float(timeout), 10.0),
1471
+ deps.runner,
1472
+ )
1473
+ if not version.ok:
1474
+ reason = {
1475
+ "manager_timeout": "manager_version_timeout",
1476
+ "manager_version_invalid": "manager_version_invalid",
1477
+ }.get(version.reason, "manager_version_unavailable")
1478
+ return _result_for_detection(
1479
+ detection,
1480
+ ActivationOutcome.GUIDANCE_ONLY,
1481
+ reason,
1482
+ )
1483
+ if not adapter_for(detection.manager).supports(version.version):
1484
+ return _result_for_detection(
1485
+ detection,
1486
+ ActivationOutcome.GUIDANCE_ONLY,
1487
+ "manager_version_unsupported",
1488
+ )
1489
+ question = (
1490
+ "Project-local TypeScript is missing. Install it with "
1491
+ f"{detection.manager.value} as a development dependency? [y/N]"
1492
+ )
1493
+ try:
1494
+ answer = deps.prompt(question)
1495
+ accepted = (
1496
+ isinstance(answer, str)
1497
+ and answer.strip().lower() in {"y", "yes"}
1498
+ )
1499
+ except EOFError:
1500
+ accepted = False
1501
+ except Exception:
1502
+ return _result_for_detection(
1503
+ detection,
1504
+ ActivationOutcome.DECLINED,
1505
+ "prompt_failed",
1506
+ )
1507
+ if not accepted:
1508
+ return _result_for_detection(
1509
+ detection,
1510
+ ActivationOutcome.DECLINED,
1511
+ "user_declined",
1512
+ )
1513
+ try:
1514
+ started = deps.monotonic()
1515
+ if not isinstance(started, (int, float)) or not math.isfinite(started):
1516
+ raise ValueError("clock_invalid")
1517
+ deadline = _Deadline(started + float(timeout), deps.monotonic)
1518
+ except Exception:
1519
+ return _result_for_detection(
1520
+ detection,
1521
+ ActivationOutcome.VALIDATION_FAILED,
1522
+ "dependency_failed",
1523
+ attempted=True,
1524
+ )
1525
+ try:
1526
+ validator_value = deps.environ.get("GRAPHITE_PACKAGE_VALIDATOR")
1527
+ except Exception:
1528
+ return _result_for_detection(
1529
+ detection,
1530
+ ActivationOutcome.VALIDATION_FAILED,
1531
+ "dependency_failed",
1532
+ attempted=True,
1533
+ )
1534
+ validator = (
1535
+ resolve_trusted_file(Path(validator_value), canonical_root, executable=False)
1536
+ if isinstance(validator_value, str) and validator_value
1537
+ else None
1538
+ )
1539
+ if validator is None:
1540
+ return _result_for_detection(
1541
+ detection,
1542
+ ActivationOutcome.VALIDATION_FAILED,
1543
+ "validator_invalid",
1544
+ attempted=True,
1545
+ )
1546
+ try:
1547
+ validation = run_validator(
1548
+ canonical_root,
1549
+ node,
1550
+ validator,
1551
+ deadline.remaining(),
1552
+ deps.runner,
1553
+ )
1554
+ deadline.remaining()
1555
+ except _DeadlineExpired:
1556
+ return _result_for_detection(
1557
+ detection,
1558
+ ActivationOutcome.VALIDATION_FAILED,
1559
+ "operation_timeout",
1560
+ attempted=True,
1561
+ )
1562
+ except _DependencyFailure:
1563
+ return _result_for_detection(
1564
+ detection,
1565
+ ActivationOutcome.VALIDATION_FAILED,
1566
+ "dependency_failed",
1567
+ attempted=True,
1568
+ )
1569
+ if not validation.ok:
1570
+ return _result_for_detection(
1571
+ detection,
1572
+ ActivationOutcome.VALIDATION_FAILED,
1573
+ validation.reason,
1574
+ attempted=True,
1575
+ )
1576
+ return _install_with_isolation(
1577
+ canonical_root,
1578
+ request.cfg,
1579
+ detection,
1580
+ node,
1581
+ command,
1582
+ deadline,
1583
+ deps,
1584
+ )
1585
+ finally:
1586
+ _release_activation_lock(key, lock)