oct
Loading...
Searching...
No Matches
option_c_dir.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/option_c_dir.py
4
5"""
6Purpose
7-------
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:
12
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/``
16
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).
21
22Responsibilities
23----------------
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.
34
35Diagnostics
36-----------
37Domain: OCT-CORE
38Levels:
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
42
43Contracts
44---------
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.
52
53Dependencies
54------------
55- oct.core.diagnostics (_dbg structured logger)
56"""
57
58from __future__ import annotations
59
60import json
61from dataclasses import asdict, dataclass, field
62from pathlib import Path
63from typing import Literal
64from oct.core.diagnostics import _dbg
65
66
67OPTION_C_DIRNAME: str = ".option_c"
68OC_STATUS_FILENAME: str = "oc_status.json"
69OC_STATUS_SCHEMA_VERSION: str = "1.0"
70
71VALID_STAGES: tuple[str, ...] = ("bootstrap", "migrating", "compliant")
72
73Stage = Literal["bootstrap", "migrating", "compliant"]
74
75
76# ---------------------------------------------------------------------
77# Path helpers
78# ---------------------------------------------------------------------
79
80
81def option_c_dir(project_root: Path) -> Path:
82 """Return ``<project_root>/.option_c`` (existence not checked)."""
83 return project_root / OPTION_C_DIRNAME
84
85
86def resolve_octrc(project_root: Path) -> Path:
87 """Return the path to ``.octrc.json``-equivalent for *project_root*.
88
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
93 project.
94 """
95 func = "resolve_octrc"
96 _dbg("OCT-CORE", func, f"resolving octrc under {project_root}", 4)
97 modern = option_c_dir(project_root) / "octrc.json"
98 if modern.is_file():
99 return modern
100 return project_root / ".octrc.json"
101
102
103def resolve_debug_config(project_root: Path) -> Path:
104 """Return the path to ``debug_config.json`` for *project_root*.
105
106 Preference order:
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)
110
111 When nothing exists, the ``oc_diagnostics/`` path is returned so
112 callers get the most-commonly-expected missing-file location.
113 """
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"
117 if modern.is_file():
118 return modern
119 oc_diag = project_root / "oc_diagnostics" / "debug_config.json"
120 if oc_diag.is_file():
121 return oc_diag
122 very_legacy = project_root / "diagnostics" / "debug_config.json"
123 if very_legacy.is_file():
124 return very_legacy
125 return oc_diag
126
127
128def resolve_logs_dir(project_root: Path) -> Path:
129 """Return the directory where OCT writes audit / run logs.
130
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``.
138 """
139 modern = option_c_dir(project_root) / "logs"
140 if modern.is_dir():
141 return modern
142 # Prefer the modern path only when .option_c/ itself exists (project
143 # has been migrated / scaffolded) - then lazy-create the logs subdir
144 # under it. On genuinely unmigrated projects, keep using root/logs/
145 # so legacy callers don't see surprise directory creation.
146 if option_c_dir(project_root).is_dir():
147 return modern
148 return project_root / "logs"
149
150
151# ---------------------------------------------------------------------
152# oc_status.json
153# ---------------------------------------------------------------------
154
155
156@dataclass
158 """Parsed ``oc_status.json`` - the project's Option C compliance state.
159
160 Fields mirror the on-disk JSON:
161
162 ``stage``
163 One of ``bootstrap`` / ``migrating`` / ``compliant`` - see
164 :data:`VALID_STAGES`. Validated at construction.
165 ``pillars``
166 Per-pillar enablement dict (``linter``, ``diagnostics``, ``git``,
167 ``docs``, ``tests``, ``scaffold``). Each entry is
168 ``{"enabled": bool, "since": str | None}``.
169 ``migrated_from``
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.
175 """
176
177 stage: Stage
178 pillars: dict = field(default_factory=dict)
179 migrated_from: dict = field(default_factory=dict)
180 oct_version_at_migration: str = ""
181
182 def __post_init__(self) -> None:
183 if self.stage not in VALID_STAGES:
184 raise ValueError(
185 f"OcStatus.stage must be one of {VALID_STAGES}, got "
186 f"{self.stage!r}"
187 )
188
189
190def load_oc_status(project_root: Path) -> OcStatus | None:
191 """Return the parsed ``oc_status.json`` or ``None`` if missing/invalid.
192
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``.
197 """
198 func = "load_oc_status"
199 _dbg("OCT-CORE", func, f"reading oc_status under {project_root}", 3)
200 path = option_c_dir(project_root) / OC_STATUS_FILENAME
201 if not path.is_file():
202 return None
203 try:
204 raw = path.read_text(encoding="utf-8")
205 data = json.loads(raw)
206 except (OSError, json.JSONDecodeError):
207 return None
208 if not isinstance(data, dict):
209 return None
210 stage = data.get("stage")
211 if stage not in VALID_STAGES:
212 return None
213 try:
214 return OcStatus(
215 stage=stage,
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 "",
219 )
220 except (TypeError, ValueError):
221 return None
222
223
224def write_oc_status(project_root: Path, status: OcStatus) -> None:
225 """Write *status* to ``<project_root>/.option_c/oc_status.json``.
226
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.
231 """
232 func = "write_oc_status"
233 _dbg("OCT-CORE", func, f"writing oc_status under {project_root}", 3)
234 target_dir = option_c_dir(project_root)
235 target_dir.mkdir(parents=True, exist_ok=True)
236
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")
240
241
242__all__ = [
243 "OPTION_C_DIRNAME",
244 "OC_STATUS_FILENAME",
245 "OC_STATUS_SCHEMA_VERSION",
246 "VALID_STAGES",
247 "OcStatus",
248 "option_c_dir",
249 "resolve_octrc",
250 "resolve_debug_config",
251 "resolve_logs_dir",
252 "load_oc_status",
253 "write_oc_status",
254]
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)