165 """Loaded representation of an Option C workspace manifest.
170 Absolute resolved path to the workspace root.
172 Display name from the manifest.
174 Tuple of :class:`Subproject` entries, in manifest order.
176 Lint profile applied to files matched by *workspace_files*.
177 Defaults to ``"compact"``.
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.
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)
197 """Lookup index from subproject name to :class:`Subproject`.
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.
279 """Load and validate the workspace manifest at ``<root>``.
284 Path to the workspace root (the directory containing
285 ``.option_c_workspace.json``).
294 If the manifest is missing, malformed, or contains invalid
297 func =
"load_workspace"
298 _dbg(
"WORKSPACE", func, f
"root={root}", 2)
300 manifest_path = Path(root) / WORKSPACE_MANIFEST
301 if not manifest_path.is_file():
303 f
"workspace manifest not found at {manifest_path}",
307 raw = manifest_path.read_text(encoding=
"utf-8")
308 data = json.loads(raw)
309 except (OSError, json.JSONDecodeError)
as exc:
311 f
"failed to read workspace manifest {manifest_path}: {exc}",
314 if not isinstance(data, dict):
316 f
"workspace manifest must be a JSON object, "
317 f
"got {type(data).__name__}",
320 version = data.get(
"version")
321 if version != _SCHEMA_VERSION:
323 f
"unsupported workspace manifest version "
324 f
"{version!r} (expected {_SCHEMA_VERSION!r})",
327 name_raw = data.get(
"name")
328 if not isinstance(name_raw, str)
or not name_raw.strip():
330 "workspace manifest 'name' must be a non-empty string",
333 subprojects_raw = data.get(
"subprojects", [])
334 if not isinstance(subprojects_raw, list):
336 f
"workspace 'subprojects' must be a list, "
337 f
"got {type(subprojects_raw).__name__}",
340 root_resolved = Path(root).resolve()
341 subprojects: list[Subproject] = []
342 seen_paths: set[Path] = set()
344 for idx, entry
in enumerate(subprojects_raw):
345 if not isinstance(entry, dict):
347 f
"subprojects[{idx}] must be an object, "
348 f
"got {type(entry).__name__}",
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)
355 if not isinstance(sp_path_raw, str)
or not sp_path_raw.strip():
357 f
"subprojects[{idx}].path must be a non-empty string",
359 sp_path_obj = Path(sp_path_raw)
360 if sp_path_obj.is_absolute():
362 f
"subprojects[{idx}].path must be relative to the "
363 f
"workspace root, got absolute path {sp_path_raw!r}",
365 sp_resolved = (root_resolved / sp_path_raw).resolve()
369 sp_resolved.relative_to(root_resolved)
372 f
"subprojects[{idx}].path {sp_path_raw!r} escapes the "
373 f
"workspace root {root_resolved}",
376 if sp_resolved
in seen_paths:
378 f
"duplicate subproject path {sp_path_raw!r}",
380 seen_paths.add(sp_resolved)
382 if not isinstance(sp_name, str)
or not sp_name.strip():
384 f
"subprojects[{idx}].name must be a non-empty string",
387 if sp_profile
is not None and (
388 not isinstance(sp_profile, str)
389 or sp_profile
not in (
"proto",
"compact",
"strict")
392 f
"subprojects[{idx}].profile must be one of "
393 f
"'proto'/'compact'/'strict' or omitted, got "
397 if not isinstance(sp_nested_git, bool):
399 f
"subprojects[{idx}].nested_git must be a boolean or "
400 f
"omitted, got {sp_nested_git!r}",
403 sp_html_docs, sp_html_docs_hub, sp_html_docs_doxygen = (
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,
418 wp_raw = data.get(
"workspace_profile")
420 workspace_profile = _DEFAULT_WORKSPACE_PROFILE
421 elif isinstance(wp_raw, str)
and wp_raw
in _VALID_PROFILES:
422 workspace_profile = wp_raw
425 f
"'workspace_profile' must be one of "
426 f
"{', '.join(repr(p) for p in _VALID_PROFILES)} or omitted, "
431 wf_raw = data.get(
"workspace_files", [])
432 if not isinstance(wf_raw, list):
434 f
"'workspace_files' must be a list of strings, "
435 f
"got {type(wf_raw).__name__}",
437 workspace_files: list[str] = []
438 for idx, entry
in enumerate(wf_raw):
442 f
"workspace_files[{idx}]: {msg}",
444 workspace_files.append(entry)
447 iaw_raw = data.get(
"ignore_at_workspace_scope", [])
448 if not isinstance(iaw_raw, list):
450 f
"'ignore_at_workspace_scope' must be a list of strings, "
451 f
"got {type(iaw_raw).__name__}",
453 ignore_at_workspace_scope: list[str] = []
454 for idx, entry
in enumerate(iaw_raw):
458 f
"ignore_at_workspace_scope[{idx}]: {msg}",
460 ignore_at_workspace_scope.append(entry)
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),
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)",