Option C Tools
Loading...
Searching...
No Matches
project_root.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/project_root.py
4
5"""
6Purpose
7-------
8Provide shared logic for detecting the root of an Option C project.
9
10Responsibilities
11----------------
12- Walk upward from the current directory.
13- Identify the project root by required directories.
14"""
15
16import json
17import sys
18from pathlib import Path
19import click
20
21
22# Maximum size of an .octrc.json file that will be read during root discovery.
23# Larger files are skipped to prevent memory exhaustion from malicious or
24# accidentally oversized config files in parent directories (OI-414).
25MAX_OCTRC_SIZE = 64 * 1024 # 64 KB
26
27
28def find_project_root(start: Path) -> Path:
29 current = start.resolve()
30 workspace_root: Path | None = None
31 for parent in [current] + list(current.parents):
32 # FS-539: .option_c/ is the canonical root marker on migrated
33 # projects — its presence alone is sufficient evidence that this
34 # directory is an Option C project root. Checked first so the
35 # detector is fastest on post-v0.19.0 projects.
36 if (parent / ".option_c").is_dir():
37 return parent
38
39 # Check for explicit root marker next
40 octrc = parent / ".octrc.json"
41 if octrc.is_file():
42 try:
43 size = octrc.stat().st_size
44 if size > MAX_OCTRC_SIZE:
45 print(
46 f"Warning: skipping {octrc} ({size} bytes > "
47 f"{MAX_OCTRC_SIZE} limit)",
48 file=sys.stderr,
49 )
50 else:
51 cfg = json.loads(octrc.read_text(encoding="utf-8"))
52 if cfg.get("root_marker", False):
53 return parent
54 except (json.JSONDecodeError, OSError):
55 pass
56
57 has_oc_diag = (parent / "oc_diagnostics").exists()
58 has_legacy = (parent / "diagnostics").exists()
59 has_diag = has_oc_diag or has_legacy
60 if (parent / "docs").exists() and (parent / "tests").exists() and has_diag:
61 if has_legacy and not has_oc_diag:
62 click.echo(
63 "Warning: 'diagnostics/' is deprecated and will be removed in v1.0.0. "
64 "Rename to 'oc_diagnostics/'.",
65 err=True,
66 )
67 return parent
68
69 # F1 / L1: workspace manifest acts as a fallback root for
70 # commands that need a working directory but no per-project
71 # markers (e.g. ``oct git status`` from the workspace root in
72 # a monorepo). Recorded but not returned yet — a project root
73 # closer to ``start`` always wins.
74 if workspace_root is None and (
75 parent / ".option_c_workspace.json"
76 ).is_file():
77 workspace_root = parent
78
79 if workspace_root is not None:
80 return workspace_root
81 raise click.ClickException("Could not locate project root.")
82
83
84def get_project_root(ctx: click.Context) -> Path:
85 """Return project root from --root-dir override or auto-detection.
86
87 When ``--root-dir`` is supplied, OI-405 requires the target directory to
88 look like an Option C project (contain ``pyproject.toml`` or
89 ``.octrc.json``). If the user intentionally wants to point at an
90 unrelated directory, they must also pass ``--allow-outside-project``,
91 which is recorded in ``ctx.obj``.
92 """
93 root_dir = ctx.obj.get("root_dir") if ctx.obj else None
94 if root_dir:
95 resolved = Path(root_dir).resolve()
96 if not resolved.is_dir():
97 raise click.ClickException(f"--root-dir path does not exist: {resolved}")
98 allow_outside = bool(ctx.obj.get("allow_outside_project")) if ctx.obj else False
99 if not allow_outside:
100 has_marker = (
101 (resolved / "pyproject.toml").is_file()
102 or (resolved / ".octrc.json").is_file()
103 or (resolved / ".option_c").is_dir() # FS-539
104 or (resolved / ".option_c_workspace.json").is_file() # F1
105 )
106 if not has_marker:
107 raise click.ClickException(
108 f"--root-dir {resolved} does not appear to be an Option C project "
109 f"(no pyproject.toml, .octrc.json, or .option_c/). Pass "
110 f"--allow-outside-project to override."
111 )
112 return resolved
113 return find_project_root(Path.cwd())
114
115
116def is_within_project_root(path: Path, project_root: Path) -> bool:
117 """Pure predicate: True if ``path`` resolves inside ``project_root``.
118
119 Symlink-safe: both sides are resolved before comparison so symlink
120 targets cannot escape the project root. Never raises — any ``OSError``
121 during resolution is treated as "not inside". Intended for library
122 callers (e.g. ``oct.core.git``) that must not depend on Click.
123 """
124 try:
125 resolved = Path(path).resolve()
126 root_resolved = Path(project_root).resolve()
127 except OSError:
128 return False
129 try:
130 resolved.relative_to(root_resolved)
131 return True
132 except ValueError:
133 return False
134
135
137 path: Path, project_root: Path, allow_outside: bool = False
138) -> Path:
139 """Resolve ``path`` and verify it is within ``project_root``.
140
141 If ``allow_outside`` is True, the resolved path is returned without
142 containment enforcement. Otherwise, a ``click.ClickException`` is raised
143 when the path falls outside ``project_root``. Used by export and clean
144 commands to prevent accidental operations on files outside the project
145 via symlink tricks or bare ``..`` paths (OI-405).
146 """
147 resolved = Path(path).resolve()
148 if allow_outside:
149 return resolved
150 root_resolved = Path(project_root).resolve()
151 if not is_within_project_root(resolved, root_resolved):
152 raise click.ClickException(
153 f"Path {resolved} is outside project root {root_resolved}. "
154 f"Use --allow-outside-project to override."
155 )
156 return resolved
Path validate_path_containment(Path path, Path project_root, bool allow_outside=False)
Path get_project_root(click.Context ctx)
Path find_project_root(Path start)
bool is_within_project_root(Path path, Path project_root)