8FS-539 — single source of truth for resolving paths to the canonical
9``.option_c/`` project dotdir (introduced in v0.19.0) with a read-only
10compat shim so projects that haven't run ``oct migrate .option_c``
11keep working through the legacy layout:
13 * ``.octrc.json`` (legacy) → ``.option_c/octrc.json``
14 * ``oc_diagnostics/debug_config.json`` (legacy) → ``.option_c/debug_config.json``
15 * ``logs/`` (legacy) → ``.option_c/logs/``
17The resolvers always prefer the modern path when present and fall back
18to the legacy path otherwise. They never raise on a missing file;
19callers are expected to handle absent configs themselves (every OCT
20config loader already does).
24- Define :data:`OPTION_C_DIRNAME`, :data:`OC_STATUS_FILENAME`, and
25 :data:`VALID_STAGES` — the canonical names consumed across OCT.
26- Provide pure path helpers (:func:`option_c_dir`,
27 :func:`resolve_octrc`, :func:`resolve_debug_config`,
28 :func:`resolve_logs_dir`) that never touch file contents beyond an
29 ``is_file`` / ``is_dir`` existence check.
30- Expose :class:`OcStatus` + :func:`load_oc_status` /
31 :func:`write_oc_status` for the migration-state file
32 (``oc_status.json``) that ``oct migrate``, ``oct audit``, and
33 ``CLAUDE.md``'s compliance probe consume.
39 (No diagnostics emitted; callers decide how to react.)
43- All public helpers are pure: no network, no mutation outside of
44 :func:`write_oc_status` which is explicitly a writer.
45- Resolvers only stat the filesystem; they never read config contents,
46 so they are safe to call during root discovery.
47- :class:`OcStatus` validates ``stage`` at construction; other fields
48 are stored as-is to keep the dataclass forwards-compatible with
49 future schema extensions.
52from __future__
import annotations
55from dataclasses
import asdict, dataclass, field
56from pathlib
import Path
57from typing
import Literal
60OPTION_C_DIRNAME: str =
".option_c"
61OC_STATUS_FILENAME: str =
"oc_status.json"
62OC_STATUS_SCHEMA_VERSION: str =
"1.0"
64VALID_STAGES: tuple[str, ...] = (
"bootstrap",
"migrating",
"compliant")
66Stage = Literal[
"bootstrap",
"migrating",
"compliant"]
75 """Return ``<project_root>/.option_c`` (existence not checked)."""
76 return project_root / OPTION_C_DIRNAME
80 """Return the path to ``.octrc.json``-equivalent for *project_root*.
82 Prefers ``.option_c/octrc.json`` when present, else
83 ``<project_root>/.octrc.json`` (legacy). The legacy path is also
84 returned when neither file exists so the error message points the
85 user at the file they're most likely looking for on an unmigrated
91 return project_root /
".octrc.json"
95 """Return the path to ``debug_config.json`` for *project_root*.
98 1. ``.option_c/debug_config.json`` (post-FS-539)
99 2. ``oc_diagnostics/debug_config.json`` (v0.17+)
100 3. ``diagnostics/debug_config.json`` (pre-v0.17 legacy)
102 When nothing exists, the ``oc_diagnostics/`` path is returned so
103 callers get the most-commonly-expected missing-file location.
105 modern =
option_c_dir(project_root) /
"debug_config.json"
108 oc_diag = project_root /
"oc_diagnostics" /
"debug_config.json"
109 if oc_diag.is_file():
111 very_legacy = project_root /
"diagnostics" /
"debug_config.json"
112 if very_legacy.is_file():
118 """Return the directory where OCT writes audit / run logs.
120 Prefers ``.option_c/logs`` when present (i.e. on a migrated or
121 freshly-scaffolded v0.19.0+ project where
122 ``.option_c/`` already exists on disk), otherwise falls back to
123 ``<project_root>/logs``. When neither exists, the legacy path is
124 returned so that ad-hoc invocations from an unmigrated working tree
125 don't spontaneously create a ``.option_c/`` side-effect — that
126 creation is reserved for ``oct scaffold`` and ``oct migrate``.
137 return project_root /
"logs"
147 """Parsed ``oc_status.json`` — the project's Option C compliance state.
149 Fields mirror the on-disk JSON:
152 One of ``bootstrap`` / ``migrating`` / ``compliant`` — see
153 :data:`VALID_STAGES`. Validated at construction.
155 Per-pillar enablement dict (``linter``, ``diagnostics``, ``git``,
156 ``docs``, ``tests``, ``scaffold``). Each entry is
157 ``{"enabled": bool, "since": str | None}``.
159 Legacy paths that were copied into ``.option_c/`` — kept for
160 ``oct audit`` to cross-reference. Empty dict on fresh scaffolds.
161 ``oct_version_at_migration``
162 The ``importlib.metadata.version("oct")`` seen when the file was
163 first written. Useful for audit trails and future schema bumps.
167 pillars: dict = field(default_factory=dict)
168 migrated_from: dict = field(default_factory=dict)
169 oct_version_at_migration: str =
""
172 if self.stage
not in VALID_STAGES:
174 f
"OcStatus.stage must be one of {VALID_STAGES}, got "
180 """Return the parsed ``oc_status.json`` or ``None`` if missing/invalid.
182 Missing-file, OSError, JSONDecodeError, and invalid-stage all
183 collapse to ``None`` so callers (``oct audit``, CLAUDE.md probe,
184 etc.) can treat "unmigrated" and "corrupt" identically — both
185 require the user to re-run ``oct migrate .option_c``.
188 if not path.is_file():
191 raw = path.read_text(encoding=
"utf-8")
192 data = json.loads(raw)
193 except (OSError, json.JSONDecodeError):
195 if not isinstance(data, dict):
197 stage = data.get(
"stage")
198 if stage
not in VALID_STAGES:
203 pillars=data.get(
"pillars", {})
or {},
204 migrated_from=data.get(
"migrated_from", {})
or {},
205 oct_version_at_migration=data.get(
"oct_version_at_migration",
"")
or "",
207 except (TypeError, ValueError):
212 """Write *status* to ``<project_root>/.option_c/oc_status.json``.
214 Creates ``.option_c/`` if it does not already exist. The on-disk
215 JSON is pretty-printed (2-space indent) and prefixed with the
216 ``_schema_version`` sentinel used elsewhere in OCT for
217 forward-compatible schema evolution.
220 target_dir.mkdir(parents=
True, exist_ok=
True)
222 payload = {
"_schema_version": OC_STATUS_SCHEMA_VERSION, **asdict(status)}
223 text = json.dumps(payload, indent=2) +
"\n"
224 (target_dir / OC_STATUS_FILENAME).write_text(text, encoding=
"utf-8")
229 "OC_STATUS_FILENAME",
230 "OC_STATUS_SCHEMA_VERSION",
235 "resolve_debug_config",
Path resolve_octrc(Path project_root)
None write_oc_status(Path project_root, OcStatus status)
Path resolve_logs_dir(Path project_root)
OcStatus|None load_oc_status(Path project_root)
Path resolve_debug_config(Path project_root)