103 """Loaded representation of an Option C workspace manifest.
108 Absolute resolved path to the workspace root.
110 Display name from the manifest.
112 Tuple of :class:`Subproject` entries, in manifest order.
114 Lint profile applied to files matched by *workspace_files*.
115 Defaults to ``"compact"``.
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.
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)
135 """Lookup index from subproject name to :class:`Subproject`.
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.
145 """Load and validate the workspace manifest at ``<root>``.
150 Path to the workspace root (the directory containing
151 ``.option_c_workspace.json``).
160 If the manifest is missing, malformed, or contains invalid
163 func =
"load_workspace"
164 _dbg(
"WORKSPACE", func, f
"root={root}", 2)
166 manifest_path = Path(root) / WORKSPACE_MANIFEST
167 if not manifest_path.is_file():
169 f
"workspace manifest not found at {manifest_path}",
173 raw = manifest_path.read_text(encoding=
"utf-8")
174 data = json.loads(raw)
175 except (OSError, json.JSONDecodeError)
as exc:
177 f
"failed to read workspace manifest {manifest_path}: {exc}",
180 if not isinstance(data, dict):
182 f
"workspace manifest must be a JSON object, "
183 f
"got {type(data).__name__}",
186 version = data.get(
"version")
187 if version != _SCHEMA_VERSION:
189 f
"unsupported workspace manifest version "
190 f
"{version!r} (expected {_SCHEMA_VERSION!r})",
193 name_raw = data.get(
"name")
194 if not isinstance(name_raw, str)
or not name_raw.strip():
196 "workspace manifest 'name' must be a non-empty string",
199 subprojects_raw = data.get(
"subprojects", [])
200 if not isinstance(subprojects_raw, list):
202 f
"workspace 'subprojects' must be a list, "
203 f
"got {type(subprojects_raw).__name__}",
206 root_resolved = Path(root).resolve()
207 subprojects: list[Subproject] = []
208 seen_paths: set[Path] = set()
210 for idx, entry
in enumerate(subprojects_raw):
211 if not isinstance(entry, dict):
213 f
"subprojects[{idx}] must be an object, "
214 f
"got {type(entry).__name__}",
216 sp_path_raw = entry.get(
"path")
217 sp_name = entry.get(
"name")
218 sp_profile = entry.get(
"profile")
220 if not isinstance(sp_path_raw, str)
or not sp_path_raw.strip():
222 f
"subprojects[{idx}].path must be a non-empty string",
224 sp_path_obj = Path(sp_path_raw)
225 if sp_path_obj.is_absolute():
227 f
"subprojects[{idx}].path must be relative to the "
228 f
"workspace root, got absolute path {sp_path_raw!r}",
230 sp_resolved = (root_resolved / sp_path_raw).resolve()
234 sp_resolved.relative_to(root_resolved)
237 f
"subprojects[{idx}].path {sp_path_raw!r} escapes the "
238 f
"workspace root {root_resolved}",
241 if sp_resolved
in seen_paths:
243 f
"duplicate subproject path {sp_path_raw!r}",
245 seen_paths.add(sp_resolved)
247 if not isinstance(sp_name, str)
or not sp_name.strip():
249 f
"subprojects[{idx}].name must be a non-empty string",
252 if sp_profile
is not None and (
253 not isinstance(sp_profile, str)
254 or sp_profile
not in (
"proto",
"compact",
"strict")
257 f
"subprojects[{idx}].profile must be one of "
258 f
"'proto'/'compact'/'strict' or omitted, got "
264 path=sp_resolved, name=sp_name, profile=sp_profile,
269 wp_raw = data.get(
"workspace_profile")
271 workspace_profile = _DEFAULT_WORKSPACE_PROFILE
272 elif isinstance(wp_raw, str)
and wp_raw
in _VALID_PROFILES:
273 workspace_profile = wp_raw
276 f
"'workspace_profile' must be one of "
277 f
"{', '.join(repr(p) for p in _VALID_PROFILES)} or omitted, "
282 wf_raw = data.get(
"workspace_files", [])
283 if not isinstance(wf_raw, list):
285 f
"'workspace_files' must be a list of strings, "
286 f
"got {type(wf_raw).__name__}",
288 workspace_files: list[str] = []
289 for idx, entry
in enumerate(wf_raw):
293 f
"workspace_files[{idx}]: {msg}",
295 workspace_files.append(entry)
298 iaw_raw = data.get(
"ignore_at_workspace_scope", [])
299 if not isinstance(iaw_raw, list):
301 f
"'ignore_at_workspace_scope' must be a list of strings, "
302 f
"got {type(iaw_raw).__name__}",
304 ignore_at_workspace_scope: list[str] = []
305 for idx, entry
in enumerate(iaw_raw):
309 f
"ignore_at_workspace_scope[{idx}]: {msg}",
311 ignore_at_workspace_scope.append(entry)
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),
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)",