Option C Tools
Loading...
Searching...
No Matches
workspace.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/workspace.py
4
5"""
6Purpose
7-------
8Workspace (monorepo) support for OCT (F1, leanest first cut). A
9workspace is a directory containing a ``.option_c_workspace.json``
10manifest and one or more Option C subprojects. ``oct git`` commands
11invoked from a workspace root may operate on the entire repo without
12the ``--root-dir <subproj> --allow-outside-project`` escape hatch
13required pre-F1.
14
15Responsibilities
16----------------
17- Define :class:`Workspace` — the loaded representation of the
18 manifest.
19- Provide :func:`load_workspace` — read and validate the manifest at
20 ``<root>/.option_c_workspace.json``.
21- Provide :func:`find_workspace_root` — walk upward from a starting
22 directory to locate the nearest workspace manifest.
23- Be intentionally conservative: invalid manifests raise
24 :class:`WorkspaceError` rather than degrading silently.
25
26Diagnostics
27-----------
28Domain: WORKSPACE
29Levels:
30 L1 — errors (invalid manifest, IO failure)
31 L2 — lifecycle (load start / end)
32 L3 — details (resolved subproject paths)
33
34Contracts
35---------
36- This module imports nothing from ``oct.git`` or ``oct.cli``; it sits
37 beneath them in the dependency graph.
38- Manifest schema is JSON-only (no TOML); the file is small enough
39 that adding a TOML parser is unwarranted.
40- Subproject paths in the manifest are interpreted relative to the
41 workspace root. Absolute paths in the manifest are rejected.
42- F1 fan-out (v0.25.0): the manifest now also carries
43 ``workspace_profile`` (default ``"compact"``), ``workspace_files``
44 (gitignore-style globs of files owned by the workspace itself
45 rather than any subproject), and ``ignore_at_workspace_scope``
46 (extra ignore patterns applied at the workspace ``.gitignore``).
47 All three are optional with documented defaults.
48"""
49
50from __future__ import annotations
51
52import functools
53import json
54import re
55from dataclasses import dataclass, field
56from pathlib import Path
57
58from oct.core.diagnostics import _dbg
59
60
61#: Filename of the workspace manifest at the workspace root.
62WORKSPACE_MANIFEST: str = ".option_c_workspace.json"
63
64#: Manifest schema version this loader understands.
65_SCHEMA_VERSION: str = "1.0"
66
67#: Valid lint profile names.
68_VALID_PROFILES: tuple[str, ...] = ("proto", "compact", "strict")
69
70#: Default profile for files matched by ``workspace_files`` when the
71#: manifest does not set ``workspace_profile`` explicitly.
72_DEFAULT_WORKSPACE_PROFILE: str = "compact"
73
74
75class WorkspaceError(Exception):
76 """Raised when a workspace manifest is invalid or unreadable."""
77
78
79@dataclass(frozen=True)
81 """One Option C subproject under a workspace.
82
83 Attributes
84 ----------
85 path
86 Absolute path to the subproject root (resolved relative to the
87 workspace root at load time).
88 name
89 Display name for the subproject.
90 profile
91 Optional override for the lint profile applied to this
92 subproject's files when scoped via the workspace. ``None``
93 means "use the subproject's own ``.octrc.json``".
94 """
95
96 path: Path
97 name: str
98 profile: str | None = None
99
100
101@dataclass(frozen=True)
103 """Loaded representation of an Option C workspace manifest.
104
105 Attributes
106 ----------
107 root
108 Absolute resolved path to the workspace root.
109 name
110 Display name from the manifest.
111 subprojects
112 Tuple of :class:`Subproject` entries, in manifest order.
113 workspace_profile
114 Lint profile applied to files matched by *workspace_files*.
115 Defaults to ``"compact"``.
116 workspace_files
117 Tuple of gitignore-style globs identifying files at the
118 workspace level (i.e. owned by no subproject). Examples:
119 ``("CLAUDE.md", ".github/**", "docs/**")``.
120 ignore_at_workspace_scope
121 Tuple of additional ignore patterns to be inserted into the
122 OCT-managed block of the workspace-root ``.gitignore`` when
123 ``oct git init`` runs at workspace scope.
124 """
125
126 root: Path
127 name: str
128 subprojects: tuple[Subproject, ...] = field(default_factory=tuple)
129 workspace_profile: str = _DEFAULT_WORKSPACE_PROFILE
130 workspace_files: tuple[str, ...] = field(default_factory=tuple)
131 ignore_at_workspace_scope: tuple[str, ...] = field(default_factory=tuple)
132
133 @property
134 def subprojects_by_name(self) -> dict[str, "Subproject"]:
135 """Lookup index from subproject name to :class:`Subproject`.
136
137 Computed eagerly on first access; cheap because subprojects
138 are bounded to a small N. The dict is regenerated per call —
139 the dataclass is frozen so we can't cache it.
140 """
141 return {sp.name: sp for sp in self.subprojects}
142
143
144def load_workspace(root: Path) -> Workspace:
145 """Load and validate the workspace manifest at ``<root>``.
146
147 Parameters
148 ----------
149 root
150 Path to the workspace root (the directory containing
151 ``.option_c_workspace.json``).
152
153 Returns
154 -------
155 :class:`Workspace`
156
157 Raises
158 ------
159 WorkspaceError
160 If the manifest is missing, malformed, or contains invalid
161 subproject paths.
162 """
163 func = "load_workspace"
164 _dbg("WORKSPACE", func, f"root={root}", 2)
165
166 manifest_path = Path(root) / WORKSPACE_MANIFEST
167 if not manifest_path.is_file():
168 raise WorkspaceError(
169 f"workspace manifest not found at {manifest_path}",
170 )
171
172 try:
173 raw = manifest_path.read_text(encoding="utf-8")
174 data = json.loads(raw)
175 except (OSError, json.JSONDecodeError) as exc:
176 raise WorkspaceError(
177 f"failed to read workspace manifest {manifest_path}: {exc}",
178 ) from exc
179
180 if not isinstance(data, dict):
181 raise WorkspaceError(
182 f"workspace manifest must be a JSON object, "
183 f"got {type(data).__name__}",
184 )
185
186 version = data.get("version")
187 if version != _SCHEMA_VERSION:
188 raise WorkspaceError(
189 f"unsupported workspace manifest version "
190 f"{version!r} (expected {_SCHEMA_VERSION!r})",
191 )
192
193 name_raw = data.get("name")
194 if not isinstance(name_raw, str) or not name_raw.strip():
195 raise WorkspaceError(
196 "workspace manifest 'name' must be a non-empty string",
197 )
198
199 subprojects_raw = data.get("subprojects", [])
200 if not isinstance(subprojects_raw, list):
201 raise WorkspaceError(
202 f"workspace 'subprojects' must be a list, "
203 f"got {type(subprojects_raw).__name__}",
204 )
205
206 root_resolved = Path(root).resolve()
207 subprojects: list[Subproject] = []
208 seen_paths: set[Path] = set()
209
210 for idx, entry in enumerate(subprojects_raw):
211 if not isinstance(entry, dict):
212 raise WorkspaceError(
213 f"subprojects[{idx}] must be an object, "
214 f"got {type(entry).__name__}",
215 )
216 sp_path_raw = entry.get("path")
217 sp_name = entry.get("name")
218 sp_profile = entry.get("profile")
219
220 if not isinstance(sp_path_raw, str) or not sp_path_raw.strip():
221 raise WorkspaceError(
222 f"subprojects[{idx}].path must be a non-empty string",
223 )
224 sp_path_obj = Path(sp_path_raw)
225 if sp_path_obj.is_absolute():
226 raise WorkspaceError(
227 f"subprojects[{idx}].path must be relative to the "
228 f"workspace root, got absolute path {sp_path_raw!r}",
229 )
230 sp_resolved = (root_resolved / sp_path_raw).resolve()
231
232 # Path-escape: subproject must live inside the workspace root.
233 try:
234 sp_resolved.relative_to(root_resolved)
235 except ValueError:
236 raise WorkspaceError(
237 f"subprojects[{idx}].path {sp_path_raw!r} escapes the "
238 f"workspace root {root_resolved}",
239 )
240
241 if sp_resolved in seen_paths:
242 raise WorkspaceError(
243 f"duplicate subproject path {sp_path_raw!r}",
244 )
245 seen_paths.add(sp_resolved)
246
247 if not isinstance(sp_name, str) or not sp_name.strip():
248 raise WorkspaceError(
249 f"subprojects[{idx}].name must be a non-empty string",
250 )
251
252 if sp_profile is not None and (
253 not isinstance(sp_profile, str)
254 or sp_profile not in ("proto", "compact", "strict")
255 ):
256 raise WorkspaceError(
257 f"subprojects[{idx}].profile must be one of "
258 f"'proto'/'compact'/'strict' or omitted, got "
259 f"{sp_profile!r}",
260 )
261
262 subprojects.append(
264 path=sp_resolved, name=sp_name, profile=sp_profile,
265 ),
266 )
267
268 # -- workspace_profile ----------------------------------------------
269 wp_raw = data.get("workspace_profile")
270 if wp_raw is None:
271 workspace_profile = _DEFAULT_WORKSPACE_PROFILE
272 elif isinstance(wp_raw, str) and wp_raw in _VALID_PROFILES:
273 workspace_profile = wp_raw
274 else:
275 raise WorkspaceError(
276 f"'workspace_profile' must be one of "
277 f"{', '.join(repr(p) for p in _VALID_PROFILES)} or omitted, "
278 f"got {wp_raw!r}",
279 )
280
281 # -- workspace_files -------------------------------------------------
282 wf_raw = data.get("workspace_files", [])
283 if not isinstance(wf_raw, list):
284 raise WorkspaceError(
285 f"'workspace_files' must be a list of strings, "
286 f"got {type(wf_raw).__name__}",
287 )
288 workspace_files: list[str] = []
289 for idx, entry in enumerate(wf_raw):
290 valid, msg = _validate_workspace_pattern(entry)
291 if not valid:
292 raise WorkspaceError(
293 f"workspace_files[{idx}]: {msg}",
294 )
295 workspace_files.append(entry)
296
297 # -- ignore_at_workspace_scope --------------------------------------
298 iaw_raw = data.get("ignore_at_workspace_scope", [])
299 if not isinstance(iaw_raw, list):
300 raise WorkspaceError(
301 f"'ignore_at_workspace_scope' must be a list of strings, "
302 f"got {type(iaw_raw).__name__}",
303 )
304 ignore_at_workspace_scope: list[str] = []
305 for idx, entry in enumerate(iaw_raw):
306 valid, msg = _validate_workspace_pattern(entry)
307 if not valid:
308 raise WorkspaceError(
309 f"ignore_at_workspace_scope[{idx}]: {msg}",
310 )
311 ignore_at_workspace_scope.append(entry)
312
313 workspace = Workspace(
314 root=root_resolved,
315 name=name_raw,
316 subprojects=tuple(subprojects),
317 workspace_profile=workspace_profile,
318 workspace_files=tuple(workspace_files),
319 ignore_at_workspace_scope=tuple(ignore_at_workspace_scope),
320 )
321 _dbg(
322 "WORKSPACE", func,
323 f"loaded: {len(subprojects)} subproject(s), "
324 f"{len(workspace_files)} workspace-files glob(s), "
325 f"{len(ignore_at_workspace_scope)} workspace-ignore pattern(s)",
326 3,
327 )
328 return workspace
329
330
331def find_workspace_root(start: Path) -> Path | None:
332 """Walk upward from *start* to locate a workspace manifest.
333
334 Returns the workspace root, or ``None`` when no manifest is found
335 by the time the filesystem root is reached.
336 """
337 func = "find_workspace_root"
338 current = Path(start).resolve()
339 for parent in [current] + list(current.parents):
340 if (parent / WORKSPACE_MANIFEST).is_file():
341 _dbg("WORKSPACE", func, f"found at {parent}", 3)
342 return parent
343 _dbg("WORKSPACE", func, f"no manifest above {start}", 4)
344 return None
345
346
347# ---------------------------------------------------------------------
348# F1 fan-out helpers (v0.25.0)
349# ---------------------------------------------------------------------
350
351#: Regex of characters allowed inside a workspace pattern. Matches the
352#: contract enforced by ``oct git ignore`` validation
353#: (``oct/git/policy.py:_VALID_PATTERN_RE``) so the two surfaces stay
354#: consistent. ``**`` glob is allowed (it's two ``*``s in the char set).
355_VALID_WORKSPACE_PATTERN_RE: re.Pattern[str] = re.compile(
356 r"^[A-Za-z0-9_./*?\‍[\‍]\-]+$",
357)
358
359
360def _validate_workspace_pattern(pattern: object) -> tuple[bool, str]:
361 """Return ``(valid, message)`` for a workspace_files / ignore pattern.
362
363 Mirrors ``oct.git.policy.validate_ignore_pattern`` so the manifest
364 surface is held to the same safety bar as ``oct git ignore``: no
365 negation, no absolute paths, no shell metacharacters, no newlines.
366 """
367 if not isinstance(pattern, str):
368 return False, (
369 f"pattern must be a string, got {type(pattern).__name__}"
370 )
371 if not pattern.strip():
372 return False, "pattern is empty"
373 if "\n" in pattern or "\r" in pattern:
374 return False, "pattern contains a newline"
375 if pattern.startswith("!"):
376 return False, "negation patterns ('!...') are not allowed"
377 if pattern.startswith("/"):
378 return False, "absolute patterns (leading '/') are not allowed"
379 if not _VALID_WORKSPACE_PATTERN_RE.match(pattern):
380 return False, (
381 "pattern contains characters outside the safe set "
382 "[A-Za-z0-9_./*?[]-]"
383 )
384 return True, ""
385
386
387@functools.lru_cache(maxsize=None)
388def _glob_to_regex(pattern: str) -> re.Pattern[str]:
389 """Compile a gitignore-style glob to a regex.
390
391 Supports::
392
393 * matches any sequence of chars within one path segment (no /)
394 ** matches across path segments, including /
395 ? matches a single non-/ char
396 [] literal char class (treated character-by-character)
397
398 Patterns are anchored: the resulting regex must match the entire
399 relative path. Caching is global and unbounded — the working set
400 of patterns per session is small and stable.
401 """
402 parts: list[str] = []
403 i = 0
404 while i < len(pattern):
405 c = pattern[i]
406 if c == "*":
407 if i + 1 < len(pattern) and pattern[i + 1] == "*":
408 # ``**`` matches zero or more path segments.
409 parts.append(".*")
410 i += 2
411 # Eat a trailing ``/`` so ``foo/**/bar`` works.
412 if i < len(pattern) and pattern[i] == "/":
413 i += 1
414 else:
415 parts.append("[^/]*")
416 i += 1
417 elif c == "?":
418 parts.append("[^/]")
419 i += 1
420 elif c in r".+()|^$\\":
421 parts.append(re.escape(c))
422 i += 1
423 else:
424 parts.append(c)
425 i += 1
426 return re.compile("^" + "".join(parts) + "$")
427
428
430 rel_path: str,
431 patterns: tuple[str, ...],
432) -> bool:
433 """Return True if *rel_path* matches any *patterns* glob.
434
435 *rel_path* must be a forward-slash POSIX-style relative path
436 (e.g. ``"docs/README.md"``). Backslashes — common on Windows —
437 must be normalised by the caller.
438 """
439 if not patterns:
440 return False
441 for pat in patterns:
442 if _glob_to_regex(pat).match(rel_path):
443 return True
444 return False
445
446
448 file_path: Path,
449 workspace: Workspace,
450) -> Subproject | None:
451 """Return the subproject containing *file_path*, or ``None``.
452
453 Uses longest-path-prefix match so nested subprojects (rare)
454 resolve to the deepest match. The returned :class:`Subproject` is
455 one of ``workspace.subprojects`` (identity-comparable). Returns
456 ``None`` when *file_path* falls outside every subproject — the
457 caller then decides whether the file matches *workspace_files*
458 or should be dropped.
459
460 Performance: O(N subprojects) per call; N is bounded by manifest
461 size and is small in practice.
462 """
463 try:
464 file_resolved = Path(file_path).resolve()
465 except OSError:
466 return None
467 matches: list[Subproject] = []
468 for sp in workspace.subprojects:
469 try:
470 file_resolved.relative_to(sp.path)
471 matches.append(sp)
472 except ValueError:
473 continue
474 if not matches:
475 return None
476 return max(matches, key=lambda s: len(str(s.path)))
dict[str, "Subproject"] subprojects_by_name(self)
Definition workspace.py:134
Workspace load_workspace(Path root)
Definition workspace.py:144
re.Pattern[str] _glob_to_regex(str pattern)
Definition workspace.py:388
bool matches_workspace_files(str rel_path, tuple[str,...] patterns)
Definition workspace.py:432
tuple[bool, str] _validate_workspace_pattern(object pattern)
Definition workspace.py:360
Subproject|None assign_to_subproject(Path file_path, Workspace workspace)
Definition workspace.py:450
Path|None find_workspace_root(Path start)
Definition workspace.py:331