oct
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 L4 - verbose tracing (workspace-root walk misses)
34
35Contracts
36---------
37- This module imports nothing from ``oct.git`` or ``oct.cli``; it sits
38 beneath them in the dependency graph.
39- Manifest schema is JSON-only (no TOML); the file is small enough
40 that adding a TOML parser is unwarranted.
41- Subproject paths in the manifest are interpreted relative to the
42 workspace root. Absolute paths in the manifest are rejected.
43- F1 fan-out (v0.25.0): the manifest now also carries
44 ``workspace_profile`` (default ``"compact"``), ``workspace_files``
45 (gitignore-style globs of files owned by the workspace itself
46 rather than any subproject), and ``ignore_at_workspace_scope``
47 (extra ignore patterns applied at the workspace ``.gitignore``).
48 All three are optional with documented defaults.
49- Plan 209: a subproject entry's ``nested_git`` must be a JSON boolean
50 or omitted (default ``False``); any other type raises
51 :class:`WorkspaceError` rather than being silently coerced, the same
52 strictness already applied to ``profile``.
53- 2026-09-26: a subproject entry may carry ``html_docs`` to declare its
54 participation in the generated docs site. It is the ONLY control over
55 which subprojects ``oct docs`` builds and cards; there is deliberately
56 no workspace-level exclusion pattern list, so precedence between two
57 competing declarations can never arise. Accepted shapes are a boolean
58 or the object ``{"hub": <bool>}``; anything else raises
59 :class:`WorkspaceError`.
60- 2026-09-28: the ``html_docs`` object also accepts ``"doxygen": <bool>``
61 (default ``True``), which decides whether the subproject's Doxygen
62 output is part of the generated docs site. It is stored as
63 :attr:`Subproject.html_docs_doxygen`; ``html_docs: false`` forces it
64 ``False``. It governs only the workspace fan-out: a direct
65 ``oct docs --doxygen`` inside the subproject still builds. Unknown keys
66 are still rejected, and the message names every supported key.
67
68Dependencies
69------------
70- oct.core.diagnostics (_dbg structured logger)
71"""
72
73from __future__ import annotations
74
75import functools
76import json
77import re
78from dataclasses import dataclass, field
79from pathlib import Path
80
81from oct.core.diagnostics import _dbg
82
83
84#: Filename of the workspace manifest at the workspace root.
85WORKSPACE_MANIFEST: str = ".option_c_workspace.json"
86
87#: Manifest schema version this loader understands.
88_SCHEMA_VERSION: str = "1.0"
89
90#: Valid lint profile names.
91_VALID_PROFILES: tuple[str, ...] = ("proto", "compact", "strict")
92
93#: Default profile for files matched by ``workspace_files`` when the
94#: manifest does not set ``workspace_profile`` explicitly.
95_DEFAULT_WORKSPACE_PROFILE: str = "compact"
96
97#: Keys a subproject's ``html_docs`` object may carry. Each maps to a
98#: boolean that defaults to True. Kept in one place so the parser and its
99#: unknown-key message cannot disagree about what is supported.
100_HTML_DOCS_KEYS: tuple[str, ...] = ("hub", "doxygen")
101
102
103class WorkspaceError(Exception):
104 """Raised when a workspace manifest is invalid or unreadable."""
105
106
107@dataclass(frozen=True)
109 """One Option C subproject under a workspace.
110
111 Attributes
112 ----------
113 path
114 Absolute path to the subproject root (resolved relative to the
115 workspace root at load time).
116 name
117 Display name for the subproject.
118 profile
119 Optional override for the lint profile applied to this
120 subproject's files when scoped via the workspace. ``None``
121 means "use the subproject's own ``.octrc.json``".
122 nested_git
123 Plan 209: True when this subproject is a foreign nested repo -
124 it has its own ``.git`` and owns its own branch policy (e.g.
125 ``oc_explorer``). This is the ONLY condition under which a
126 subproject's ``git.protected_branches`` opt-out is honored
127 verbatim; every other subproject may add to but never remove
128 the workspace protected-branch defaults (see
129 ``oct.git.policy.is_protected_branch``). Defaults False.
130 html_docs
131 True when ``oct docs`` should build this subproject's HTML
132 documentation. False removes it from the generated site
133 entirely - no page tree, no aggregated output, no hub card.
134 Defaults True, so a manifest written before this field existed
135 keeps its previous behaviour.
136 html_docs_hub
137 True when the subproject should get a card on the docs hub
138 index page. Defaults True. Independent of *html_docs* only in
139 one direction: a subproject that is built may be kept off the
140 hub (browsable at a URL without crowding the index), but a
141 subproject that is not built can never be carded, so
142 ``html_docs=False`` forces this False at load time.
143 html_docs_doxygen
144 True when the subproject's Doxygen output is part of the
145 generated docs site (manifest ``html_docs: {"doxygen": <bool>}``).
146 Defaults True. False keeps the workspace fan-out from building or
147 aggregating it, withdraws any ``_site/<name>/doxygen/`` an earlier
148 run left, and drops the Doxygen card from the hub. A direct
149 ``oct docs --doxygen`` inside the subproject ignores it.
150 ``html_docs=False`` forces this False at load time, for the same
151 reason it forces *html_docs_hub* False.
152 """
153
154 path: Path
155 name: str
156 profile: str | None = None
157 nested_git: bool = False
158 html_docs: bool = True
159 html_docs_hub: bool = True
160 html_docs_doxygen: bool = True
161
162
163@dataclass(frozen=True)
165 """Loaded representation of an Option C workspace manifest.
166
167 Attributes
168 ----------
169 root
170 Absolute resolved path to the workspace root.
171 name
172 Display name from the manifest.
173 subprojects
174 Tuple of :class:`Subproject` entries, in manifest order.
175 workspace_profile
176 Lint profile applied to files matched by *workspace_files*.
177 Defaults to ``"compact"``.
178 workspace_files
179 Tuple of gitignore-style globs identifying files at the
180 workspace level (i.e. owned by no subproject). Examples:
181 ``("CLAUDE.md", ".github/**", "docs/**")``.
182 ignore_at_workspace_scope
183 Tuple of additional ignore patterns to be inserted into the
184 OCT-managed block of the workspace-root ``.gitignore`` when
185 ``oct git init`` runs at workspace scope.
186 """
187
188 root: Path
189 name: str
190 subprojects: tuple[Subproject, ...] = field(default_factory=tuple)
191 workspace_profile: str = _DEFAULT_WORKSPACE_PROFILE
192 workspace_files: tuple[str, ...] = field(default_factory=tuple)
193 ignore_at_workspace_scope: tuple[str, ...] = field(default_factory=tuple)
194
195 @property
196 def subprojects_by_name(self) -> dict[str, "Subproject"]:
197 """Lookup index from subproject name to :class:`Subproject`.
198
199 Computed eagerly on first access; cheap because subprojects
200 are bounded to a small N. The dict is regenerated per call -
201 the dataclass is frozen so we can't cache it.
202 """
203 return {sp.name: sp for sp in self.subprojects}
204
205
206def _parse_html_docs(raw: object, idx: int) -> tuple[bool, bool, bool]:
207 """Parse a subproject's ``html_docs`` declaration.
208
209 Parameters
210 ----------
211 raw
212 The raw manifest value, or ``None`` when the key is absent.
213 idx
214 Index of the owning ``subprojects[]`` entry, used only to make
215 the error message point at the offending entry.
216
217 Returns
218 -------
219 tuple[bool, bool, bool]
220 ``(html_docs, html_docs_hub, html_docs_doxygen)``.
221
222 Raises
223 ------
224 WorkspaceError
225 On any shape other than ``None``, a boolean, or an object whose
226 keys are drawn from :data:`_HTML_DOCS_KEYS` and each mapped to a
227 boolean. Rejecting unknown keys rather than ignoring them means a
228 typo such as ``{"hubb": false}`` fails loudly instead of quietly
229 leaving the card in place, which is the failure mode the operator
230 would have had to notice by eye on the rendered page.
231 """
232 func = "_parse_html_docs"
233
234 if raw is None:
235 return True, True, True
236
237 if isinstance(raw, bool):
238 # False suppresses the build, and an unbuilt subproject can be
239 # neither carded nor publish Doxygen, so both flags follow rather
240 # than being independent.
241 _dbg("WORKSPACE", func, f"subprojects[{idx}] html_docs={raw}", 4)
242 return raw, raw, raw
243
244 if isinstance(raw, dict):
245 supported = ", ".join(repr(k) for k in _HTML_DOCS_KEYS)
246 unknown = sorted(set(raw) - set(_HTML_DOCS_KEYS))
247 if unknown:
248 raise WorkspaceError(
249 f"subprojects[{idx}].html_docs has unknown key(s) "
250 f"{', '.join(repr(k) for k in unknown)}; the supported "
251 f"keys are {supported}",
252 )
253 flags: dict[str, bool] = {}
254 for key in _HTML_DOCS_KEYS:
255 value = raw.get(key, True)
256 if not isinstance(value, bool):
257 raise WorkspaceError(
258 f"subprojects[{idx}].html_docs.{key} must be a boolean, "
259 f"got {value!r}",
260 )
261 flags[key] = value
262 _dbg(
263 "WORKSPACE", func,
264 f"subprojects[{idx}] html_docs hub={flags['hub']} "
265 f"doxygen={flags['doxygen']}",
266 4,
267 )
268 return True, flags["hub"], flags["doxygen"]
269
270 raise WorkspaceError(
271 # OCT-LINT: disable=no_hardcoded_secrets reason="Fixed user-facing manifest error message; no credential material (operator-ruled 2026-09-28)"
272 f"subprojects[{idx}].html_docs must be a boolean, an object "
273 f"{{'hub': <bool>, 'doxygen': <bool>}} (either key optional), "
274 f"or omitted, got {raw!r}",
275 )
276
277
278def load_workspace(root: Path) -> Workspace:
279 """Load and validate the workspace manifest at ``<root>``.
280
281 Parameters
282 ----------
283 root
284 Path to the workspace root (the directory containing
285 ``.option_c_workspace.json``).
286
287 Returns
288 -------
289 :class:`Workspace`
290
291 Raises
292 ------
293 WorkspaceError
294 If the manifest is missing, malformed, or contains invalid
295 subproject paths.
296 """
297 func = "load_workspace"
298 _dbg("WORKSPACE", func, f"root={root}", 2)
299
300 manifest_path = Path(root) / WORKSPACE_MANIFEST
301 if not manifest_path.is_file():
302 raise WorkspaceError(
303 f"workspace manifest not found at {manifest_path}",
304 )
305
306 try:
307 raw = manifest_path.read_text(encoding="utf-8")
308 data = json.loads(raw)
309 except (OSError, json.JSONDecodeError) as exc:
310 raise WorkspaceError(
311 f"failed to read workspace manifest {manifest_path}: {exc}",
312 ) from exc
313
314 if not isinstance(data, dict):
315 raise WorkspaceError(
316 f"workspace manifest must be a JSON object, "
317 f"got {type(data).__name__}",
318 )
319
320 version = data.get("version")
321 if version != _SCHEMA_VERSION:
322 raise WorkspaceError(
323 f"unsupported workspace manifest version "
324 f"{version!r} (expected {_SCHEMA_VERSION!r})",
325 )
326
327 name_raw = data.get("name")
328 if not isinstance(name_raw, str) or not name_raw.strip():
329 raise WorkspaceError(
330 "workspace manifest 'name' must be a non-empty string",
331 )
332
333 subprojects_raw = data.get("subprojects", [])
334 if not isinstance(subprojects_raw, list):
335 raise WorkspaceError(
336 f"workspace 'subprojects' must be a list, "
337 f"got {type(subprojects_raw).__name__}",
338 )
339
340 root_resolved = Path(root).resolve()
341 subprojects: list[Subproject] = []
342 seen_paths: set[Path] = set()
343
344 for idx, entry in enumerate(subprojects_raw):
345 if not isinstance(entry, dict):
346 raise WorkspaceError(
347 f"subprojects[{idx}] must be an object, "
348 f"got {type(entry).__name__}",
349 )
350 sp_path_raw = entry.get("path")
351 sp_name = entry.get("name")
352 sp_profile = entry.get("profile")
353 sp_nested_git = entry.get("nested_git", False)
354
355 if not isinstance(sp_path_raw, str) or not sp_path_raw.strip():
356 raise WorkspaceError(
357 f"subprojects[{idx}].path must be a non-empty string",
358 )
359 sp_path_obj = Path(sp_path_raw)
360 if sp_path_obj.is_absolute():
361 raise WorkspaceError(
362 f"subprojects[{idx}].path must be relative to the "
363 f"workspace root, got absolute path {sp_path_raw!r}",
364 )
365 sp_resolved = (root_resolved / sp_path_raw).resolve()
366
367 # Path-escape: subproject must live inside the workspace root.
368 try:
369 sp_resolved.relative_to(root_resolved)
370 except ValueError:
371 raise WorkspaceError(
372 f"subprojects[{idx}].path {sp_path_raw!r} escapes the "
373 f"workspace root {root_resolved}",
374 )
375
376 if sp_resolved in seen_paths:
377 raise WorkspaceError(
378 f"duplicate subproject path {sp_path_raw!r}",
379 )
380 seen_paths.add(sp_resolved)
381
382 if not isinstance(sp_name, str) or not sp_name.strip():
383 raise WorkspaceError(
384 f"subprojects[{idx}].name must be a non-empty string",
385 )
386
387 if sp_profile is not None and (
388 not isinstance(sp_profile, str)
389 or sp_profile not in ("proto", "compact", "strict")
390 ):
391 raise WorkspaceError(
392 f"subprojects[{idx}].profile must be one of "
393 f"'proto'/'compact'/'strict' or omitted, got "
394 f"{sp_profile!r}",
395 )
396
397 if not isinstance(sp_nested_git, bool):
398 raise WorkspaceError(
399 f"subprojects[{idx}].nested_git must be a boolean or "
400 f"omitted, got {sp_nested_git!r}",
401 )
402
403 sp_html_docs, sp_html_docs_hub, sp_html_docs_doxygen = (
404 _parse_html_docs(entry.get("html_docs"), idx)
405 )
406
407 subprojects.append(
409 path=sp_resolved, name=sp_name, profile=sp_profile,
410 nested_git=sp_nested_git,
411 html_docs=sp_html_docs,
412 html_docs_hub=sp_html_docs_hub,
413 html_docs_doxygen=sp_html_docs_doxygen,
414 ),
415 )
416
417 # -- workspace_profile ----------------------------------------------
418 wp_raw = data.get("workspace_profile")
419 if wp_raw is None:
420 workspace_profile = _DEFAULT_WORKSPACE_PROFILE
421 elif isinstance(wp_raw, str) and wp_raw in _VALID_PROFILES:
422 workspace_profile = wp_raw
423 else:
424 raise WorkspaceError(
425 f"'workspace_profile' must be one of "
426 f"{', '.join(repr(p) for p in _VALID_PROFILES)} or omitted, "
427 f"got {wp_raw!r}",
428 )
429
430 # -- workspace_files -------------------------------------------------
431 wf_raw = data.get("workspace_files", [])
432 if not isinstance(wf_raw, list):
433 raise WorkspaceError(
434 f"'workspace_files' must be a list of strings, "
435 f"got {type(wf_raw).__name__}",
436 )
437 workspace_files: list[str] = []
438 for idx, entry in enumerate(wf_raw):
439 valid, msg = _validate_workspace_pattern(entry)
440 if not valid:
441 raise WorkspaceError(
442 f"workspace_files[{idx}]: {msg}",
443 )
444 workspace_files.append(entry)
445
446 # -- ignore_at_workspace_scope --------------------------------------
447 iaw_raw = data.get("ignore_at_workspace_scope", [])
448 if not isinstance(iaw_raw, list):
449 raise WorkspaceError(
450 f"'ignore_at_workspace_scope' must be a list of strings, "
451 f"got {type(iaw_raw).__name__}",
452 )
453 ignore_at_workspace_scope: list[str] = []
454 for idx, entry in enumerate(iaw_raw):
455 valid, msg = _validate_workspace_pattern(entry)
456 if not valid:
457 raise WorkspaceError(
458 f"ignore_at_workspace_scope[{idx}]: {msg}",
459 )
460 ignore_at_workspace_scope.append(entry)
461
462 workspace = Workspace(
463 root=root_resolved,
464 name=name_raw,
465 subprojects=tuple(subprojects),
466 workspace_profile=workspace_profile,
467 workspace_files=tuple(workspace_files),
468 ignore_at_workspace_scope=tuple(ignore_at_workspace_scope),
469 )
470 _dbg(
471 "WORKSPACE", func,
472 f"loaded: {len(subprojects)} subproject(s), "
473 f"{len(workspace_files)} workspace-files glob(s), "
474 f"{len(ignore_at_workspace_scope)} workspace-ignore pattern(s)",
475 3,
476 )
477 return workspace
478
479
480def find_workspace_root(start: Path) -> Path | None:
481 """Walk upward from *start* to locate a workspace manifest.
482
483 Returns the workspace root, or ``None`` when no manifest is found
484 by the time the filesystem root is reached.
485 """
486 func = "find_workspace_root"
487 current = Path(start).resolve()
488 for parent in [current] + list(current.parents):
489 if (parent / WORKSPACE_MANIFEST).is_file():
490 _dbg("WORKSPACE", func, f"found at {parent}", 3)
491 return parent
492 _dbg("WORKSPACE", func, f"no manifest above {start}", 4)
493 return None
494
495
496# ---------------------------------------------------------------------
497# F1 fan-out helpers (v0.25.0)
498# ---------------------------------------------------------------------
499
500#: Regex of characters allowed inside a workspace pattern. Matches the
501#: contract enforced by ``oct git ignore`` validation
502#: (``oct/git/policy.py:_VALID_PATTERN_RE``) so the two surfaces stay
503#: consistent. ``**`` glob is allowed (it's two ``*``s in the char set).
504_VALID_WORKSPACE_PATTERN_RE: re.Pattern[str] = re.compile(
505 r"^[A-Za-z0-9_./*?\‍[\‍]\-]+$",
506)
507
508
509def _validate_workspace_pattern(pattern: object) -> tuple[bool, str]:
510 """Return ``(valid, message)`` for a workspace_files / ignore pattern.
511
512 Mirrors ``oct.git.policy.validate_ignore_pattern`` so the manifest
513 surface is held to the same safety bar as ``oct git ignore``: no
514 negation, no absolute paths, no shell metacharacters, no newlines.
515 """
516 if not isinstance(pattern, str):
517 return False, (
518 f"pattern must be a string, got {type(pattern).__name__}"
519 )
520 if not pattern.strip():
521 return False, "pattern is empty"
522 if "\n" in pattern or "\r" in pattern:
523 return False, "pattern contains a newline"
524 if pattern.startswith("!"):
525 return False, "negation patterns ('!...') are not allowed"
526 if pattern.startswith("/"):
527 return False, "absolute patterns (leading '/') are not allowed"
528 if not _VALID_WORKSPACE_PATTERN_RE.match(pattern):
529 return False, (
530 "pattern contains characters outside the safe set "
531 "[A-Za-z0-9_./*?[]-]"
532 )
533 return True, ""
534
535
536@functools.lru_cache(maxsize=None)
537def _glob_to_regex(pattern: str) -> re.Pattern[str]:
538 """Compile a gitignore-style glob to a regex.
539
540 Supports::
541
542 * matches any sequence of chars within one path segment (no /)
543 ** matches across path segments, including /
544 ? matches a single non-/ char
545 [] literal char class (treated character-by-character)
546
547 Patterns are anchored: the resulting regex must match the entire
548 relative path. Caching is global and unbounded - the working set
549 of patterns per session is small and stable.
550 """
551 parts: list[str] = []
552 i = 0
553 while i < len(pattern):
554 c = pattern[i]
555 if c == "*":
556 if i + 1 < len(pattern) and pattern[i + 1] == "*":
557 # ``**`` matches zero or more path segments.
558 parts.append(".*")
559 i += 2
560 # Eat a trailing ``/`` so ``foo/**/bar`` works.
561 if i < len(pattern) and pattern[i] == "/":
562 i += 1
563 else:
564 parts.append("[^/]*")
565 i += 1
566 elif c == "?":
567 parts.append("[^/]")
568 i += 1
569 elif c in r".+()|^$\\":
570 parts.append(re.escape(c))
571 i += 1
572 else:
573 parts.append(c)
574 i += 1
575 return re.compile("^" + "".join(parts) + "$")
576
577
579 rel_path: str,
580 patterns: tuple[str, ...],
581) -> bool:
582 """Return True if *rel_path* matches any *patterns* glob.
583
584 *rel_path* must be a forward-slash POSIX-style relative path
585 (e.g. ``"docs/README.md"``). Backslashes - common on Windows -
586 must be normalised by the caller.
587 """
588 if not patterns:
589 return False
590 for pat in patterns:
591 if _glob_to_regex(pat).match(rel_path):
592 return True
593 return False
594
595
597 file_path: Path,
598 workspace: Workspace,
599) -> Subproject | None:
600 """Return the subproject containing *file_path*, or ``None``.
601
602 Uses longest-path-prefix match so nested subprojects (rare)
603 resolve to the deepest match. The returned :class:`Subproject` is
604 one of ``workspace.subprojects`` (identity-comparable). Returns
605 ``None`` when *file_path* falls outside every subproject - the
606 caller then decides whether the file matches *workspace_files*
607 or should be dropped.
608
609 Performance: O(N subprojects) per call; N is bounded by manifest
610 size and is small in practice.
611 """
612 try:
613 file_resolved = Path(file_path).resolve()
614 except OSError:
615 return None
616 matches: list[Subproject] = []
617 for sp in workspace.subprojects:
618 try:
619 file_resolved.relative_to(sp.path)
620 matches.append(sp)
621 except ValueError:
622 continue
623 if not matches:
624 return None
625 return max(matches, key=lambda s: len(str(s.path)))
dict[str, "Subproject"] subprojects_by_name(self)
Definition workspace.py:196
Workspace load_workspace(Path root)
Definition workspace.py:278
re.Pattern[str] _glob_to_regex(str pattern)
Definition workspace.py:537
bool matches_workspace_files(str rel_path, tuple[str,...] patterns)
Definition workspace.py:581
tuple[bool, str] _validate_workspace_pattern(object pattern)
Definition workspace.py:509
tuple[bool, bool, bool] _parse_html_docs(object raw, int idx)
Definition workspace.py:206
Subproject|None assign_to_subproject(Path file_path, Workspace workspace)
Definition workspace.py:599
Path|None find_workspace_root(Path start)
Definition workspace.py:480