Option C Tools
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 (No diagnostics emitted; callers decide how to react.)
40
41Contracts
42---------
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.
50"""
51
52from __future__ import annotations
53
54import json
55from dataclasses import asdict, dataclass, field
56from pathlib import Path
57from typing import Literal
58
59
60OPTION_C_DIRNAME: str = ".option_c"
61OC_STATUS_FILENAME: str = "oc_status.json"
62OC_STATUS_SCHEMA_VERSION: str = "1.0"
63
64VALID_STAGES: tuple[str, ...] = ("bootstrap", "migrating", "compliant")
65
66Stage = Literal["bootstrap", "migrating", "compliant"]
67
68
69# ---------------------------------------------------------------------
70# Path helpers
71# ---------------------------------------------------------------------
72
73
74def option_c_dir(project_root: Path) -> Path:
75 """Return ``<project_root>/.option_c`` (existence not checked)."""
76 return project_root / OPTION_C_DIRNAME
77
78
79def resolve_octrc(project_root: Path) -> Path:
80 """Return the path to ``.octrc.json``-equivalent for *project_root*.
81
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
86 project.
87 """
88 modern = option_c_dir(project_root) / "octrc.json"
89 if modern.is_file():
90 return modern
91 return project_root / ".octrc.json"
92
93
94def resolve_debug_config(project_root: Path) -> Path:
95 """Return the path to ``debug_config.json`` for *project_root*.
96
97 Preference order:
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)
101
102 When nothing exists, the ``oc_diagnostics/`` path is returned so
103 callers get the most-commonly-expected missing-file location.
104 """
105 modern = option_c_dir(project_root) / "debug_config.json"
106 if modern.is_file():
107 return modern
108 oc_diag = project_root / "oc_diagnostics" / "debug_config.json"
109 if oc_diag.is_file():
110 return oc_diag
111 very_legacy = project_root / "diagnostics" / "debug_config.json"
112 if very_legacy.is_file():
113 return very_legacy
114 return oc_diag
115
116
117def resolve_logs_dir(project_root: Path) -> Path:
118 """Return the directory where OCT writes audit / run logs.
119
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``.
127 """
128 modern = option_c_dir(project_root) / "logs"
129 if modern.is_dir():
130 return modern
131 # Prefer the modern path only when .option_c/ itself exists (project
132 # has been migrated / scaffolded) — then lazy-create the logs subdir
133 # under it. On genuinely unmigrated projects, keep using root/logs/
134 # so legacy callers don't see surprise directory creation.
135 if option_c_dir(project_root).is_dir():
136 return modern
137 return project_root / "logs"
138
139
140# ---------------------------------------------------------------------
141# oc_status.json
142# ---------------------------------------------------------------------
143
144
145@dataclass
147 """Parsed ``oc_status.json`` — the project's Option C compliance state.
148
149 Fields mirror the on-disk JSON:
150
151 ``stage``
152 One of ``bootstrap`` / ``migrating`` / ``compliant`` — see
153 :data:`VALID_STAGES`. Validated at construction.
154 ``pillars``
155 Per-pillar enablement dict (``linter``, ``diagnostics``, ``git``,
156 ``docs``, ``tests``, ``scaffold``). Each entry is
157 ``{"enabled": bool, "since": str | None}``.
158 ``migrated_from``
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.
164 """
165
166 stage: Stage
167 pillars: dict = field(default_factory=dict)
168 migrated_from: dict = field(default_factory=dict)
169 oct_version_at_migration: str = ""
170
171 def __post_init__(self) -> None:
172 if self.stage not in VALID_STAGES:
173 raise ValueError(
174 f"OcStatus.stage must be one of {VALID_STAGES}, got "
175 f"{self.stage!r}"
176 )
177
178
179def load_oc_status(project_root: Path) -> OcStatus | None:
180 """Return the parsed ``oc_status.json`` or ``None`` if missing/invalid.
181
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``.
186 """
187 path = option_c_dir(project_root) / OC_STATUS_FILENAME
188 if not path.is_file():
189 return None
190 try:
191 raw = path.read_text(encoding="utf-8")
192 data = json.loads(raw)
193 except (OSError, json.JSONDecodeError):
194 return None
195 if not isinstance(data, dict):
196 return None
197 stage = data.get("stage")
198 if stage not in VALID_STAGES:
199 return None
200 try:
201 return OcStatus(
202 stage=stage,
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 "",
206 )
207 except (TypeError, ValueError):
208 return None
209
210
211def write_oc_status(project_root: Path, status: OcStatus) -> None:
212 """Write *status* to ``<project_root>/.option_c/oc_status.json``.
213
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.
218 """
219 target_dir = option_c_dir(project_root)
220 target_dir.mkdir(parents=True, exist_ok=True)
221
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")
225
226
227__all__ = [
228 "OPTION_C_DIRNAME",
229 "OC_STATUS_FILENAME",
230 "OC_STATUS_SCHEMA_VERSION",
231 "VALID_STAGES",
232 "OcStatus",
233 "option_c_dir",
234 "resolve_octrc",
235 "resolve_debug_config",
236 "resolve_logs_dir",
237 "load_oc_status",
238 "write_oc_status",
239]
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)