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 L2 - (none) callers decide how to react to a missing path
40 L3 - oc_status read and write entry, naming the project root
41 L4 - octrc and debug-config path resolution
45- All public helpers are pure: no network, no mutation outside of
46 :func:`write_oc_status` which is explicitly a writer.
47- Resolvers only stat the filesystem; they never read config contents,
48 so they are safe to call during root discovery.
49- :class:`OcStatus` validates ``stage`` at construction; other fields
50 are stored as-is to keep the dataclass forwards-compatible with
51 future schema extensions.
55- oct.core.diagnostics (_dbg structured logger)
58from __future__
import annotations
61from dataclasses
import asdict, dataclass, field
62from pathlib
import Path
63from typing
import Literal
64from oct.core.diagnostics
import _dbg
67OPTION_C_DIRNAME: str =
".option_c"
68OC_STATUS_FILENAME: str =
"oc_status.json"
69OC_STATUS_SCHEMA_VERSION: str =
"1.0"
71VALID_STAGES: tuple[str, ...] = (
"bootstrap",
"migrating",
"compliant")
73Stage = Literal[
"bootstrap",
"migrating",
"compliant"]
82 """Return ``<project_root>/.option_c`` (existence not checked)."""
83 return project_root / OPTION_C_DIRNAME
87 """Return the path to ``.octrc.json``-equivalent for *project_root*.
89 Prefers ``.option_c/octrc.json`` when present, else
90 ``<project_root>/.octrc.json`` (legacy). The legacy path is also
91 returned when neither file exists so the error message points the
92 user at the file they're most likely looking for on an unmigrated
95 func =
"resolve_octrc"
96 _dbg(
"OCT-CORE", func, f
"resolving octrc under {project_root}", 4)
100 return project_root /
".octrc.json"
104 """Return the path to ``debug_config.json`` for *project_root*.
107 1. ``.option_c/debug_config.json`` (post-FS-539)
108 2. ``oc_diagnostics/debug_config.json`` (v0.17+)
109 3. ``diagnostics/debug_config.json`` (pre-v0.17 legacy)
111 When nothing exists, the ``oc_diagnostics/`` path is returned so
112 callers get the most-commonly-expected missing-file location.
114 func =
"resolve_debug_config"
115 _dbg(
"OCT-CORE", func, f
"resolving debug config under {project_root}", 4)
116 modern =
option_c_dir(project_root) /
"debug_config.json"
119 oc_diag = project_root /
"oc_diagnostics" /
"debug_config.json"
120 if oc_diag.is_file():
122 very_legacy = project_root /
"diagnostics" /
"debug_config.json"
123 if very_legacy.is_file():
129 """Return the directory where OCT writes audit / run logs.
131 Prefers ``.option_c/logs`` when present (i.e. on a migrated or
132 freshly-scaffolded v0.19.0+ project where
133 ``.option_c/`` already exists on disk), otherwise falls back to
134 ``<project_root>/logs``. When neither exists, the legacy path is
135 returned so that ad-hoc invocations from an unmigrated working tree
136 don't spontaneously create a ``.option_c/`` side-effect - that
137 creation is reserved for ``oct scaffold`` and ``oct migrate``.
148 return project_root /
"logs"
158 """Parsed ``oc_status.json`` - the project's Option C compliance state.
160 Fields mirror the on-disk JSON:
163 One of ``bootstrap`` / ``migrating`` / ``compliant`` - see
164 :data:`VALID_STAGES`. Validated at construction.
166 Per-pillar enablement dict (``linter``, ``diagnostics``, ``git``,
167 ``docs``, ``tests``, ``scaffold``). Each entry is
168 ``{"enabled": bool, "since": str | None}``.
170 Legacy paths that were copied into ``.option_c/`` - kept for
171 ``oct audit`` to cross-reference. Empty dict on fresh scaffolds.
172 ``oct_version_at_migration``
173 The ``importlib.metadata.version("oct")`` seen when the file was
174 first written. Useful for audit trails and future schema bumps.
178 pillars: dict = field(default_factory=dict)
179 migrated_from: dict = field(default_factory=dict)
180 oct_version_at_migration: str =
""
183 if self.stage
not in VALID_STAGES:
185 f
"OcStatus.stage must be one of {VALID_STAGES}, got "
191 """Return the parsed ``oc_status.json`` or ``None`` if missing/invalid.
193 Missing-file, OSError, JSONDecodeError, and invalid-stage all
194 collapse to ``None`` so callers (``oct audit``, CLAUDE.md probe,
195 etc.) can treat "unmigrated" and "corrupt" identically - both
196 require the user to re-run ``oct migrate .option_c``.
198 func =
"load_oc_status"
199 _dbg(
"OCT-CORE", func, f
"reading oc_status under {project_root}", 3)
201 if not path.is_file():
204 raw = path.read_text(encoding=
"utf-8")
205 data = json.loads(raw)
206 except (OSError, json.JSONDecodeError):
208 if not isinstance(data, dict):
210 stage = data.get(
"stage")
211 if stage
not in VALID_STAGES:
216 pillars=data.get(
"pillars", {})
or {},
217 migrated_from=data.get(
"migrated_from", {})
or {},
218 oct_version_at_migration=data.get(
"oct_version_at_migration",
"")
or "",
220 except (TypeError, ValueError):
225 """Write *status* to ``<project_root>/.option_c/oc_status.json``.
227 Creates ``.option_c/`` if it does not already exist. The on-disk
228 JSON is pretty-printed (2-space indent) and prefixed with the
229 ``_schema_version`` sentinel used elsewhere in OCT for
230 forward-compatible schema evolution.
232 func =
"write_oc_status"
233 _dbg(
"OCT-CORE", func, f
"writing oc_status under {project_root}", 3)
235 target_dir.mkdir(parents=
True, exist_ok=
True)
237 payload = {
"_schema_version": OC_STATUS_SCHEMA_VERSION, **asdict(status)}
238 text = json.dumps(payload, indent=2) +
"\n"
239 (target_dir / OC_STATUS_FILENAME).write_text(text, encoding=
"utf-8")
244 "OC_STATUS_FILENAME",
245 "OC_STATUS_SCHEMA_VERSION",
250 "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)