8Provide oc_diagnostics configuration management tools for the OCT CLI.
12- Load, validate, and modify ``oc_diagnostics/debug_config.json``.
13- List configured domains with their levels and colors.
14- Update domain debug levels in-place.
15- Report validation warnings without importing oc_diagnostics at runtime
16 (preserving the dev-time/runtime architectural boundary).
28- Must not import ``oc_diagnostics`` - operates purely on the JSON config file.
29- Must preserve JSON formatting and key order when writing back.
30- Level values must be integers 0-9, in either form oc_diagnostics accepts:
31 compact (``"GIT": 3``) or rich (``"GIT": {"level": 3, "color": "cyan"}``).
32 Until 2026-09-28 this module allowed only 0-4 and the rich form, so a
33 valid config such as ``{"*": 9}`` (open every domain for a trace) failed
34 ``oct diag validate-config`` and crashed ``list-domains``.
38- oct.core.option_c_dir (debug_config.json path resolution)
39- oct.core.diagnostics (_dbg structured logger)
43from pathlib
import Path
45from oct.core.option_c_dir
import resolve_debug_config
46from oct.core.diagnostics
import _dbg
50VALID_LEVELS = set(range(10))
53VALID_LOG_FORMATS = (
"text",
"jsonl")
56CURRENT_SCHEMA_VERSION =
"2.0"
58EXPECTED_GLOBAL_KEYS = {
59 "silent_mode",
"enable_timestamps",
"enable_colors",
"include_filename",
60 "output_mode",
"instrumentation_enabled",
"max_log_files",
"max_log_size_mb",
61 "instrumentation_allow_all",
"mode",
"http_redact_headers",
62 "ui_event_properties",
"structural_id_prefixes",
"structural_id_exact",
67 """Locate debug_config.json (FS-539: `.option_c/`-first with fallbacks).
69 Delegates to :func:`oct.core.option_c_dir.resolve_debug_config`, which
70 tries ``.option_c/debug_config.json`` first, then
71 ``oc_diagnostics/debug_config.json`` (v0.17+), then
72 ``diagnostics/debug_config.json`` (pre-v0.17).
74 return resolve_debug_config(project_root)
78 """Load and parse debug_config.json. Raises on missing/invalid file."""
80 _dbg(
"OCT-DIAG", func,
"loading the debug config", 3)
82 if not config_path.is_file():
83 raise FileNotFoundError(f
"Config not found: {config_path}")
84 text = config_path.read_text(encoding=
"utf-8")
85 return json.loads(text)
89 """OI-508 / FS-515: return ``(domain_map, is_legacy)`` from a config dict.
91 Accepts both the legacy v1 ``domains:`` layout and the current v2
92 ``DEBUG_LEVELS:`` layout. The returned map is always in the v2 shape
93 (``{name: {"level": int, "color": str}}``) regardless of what the file
94 contains - call sites can read the result uniformly.
96 ``is_legacy`` is True whenever the file used the ``domains:`` key
97 **and** the ``DEBUG_LEVELS:`` key was absent - that is the trigger
98 for the migration hint.
100 if "DEBUG_LEVELS" in config
and isinstance(config[
"DEBUG_LEVELS"], dict):
104 name: dict(entry)
if isinstance(entry, dict)
105 else {
"level": entry,
"color":
"default"}
106 for name, entry
in config[
"DEBUG_LEVELS"].items()
108 legacy = config.get(
"domains")
109 if not isinstance(legacy, dict):
111 normalised: dict = {}
112 for name, entry
in legacy.items():
113 if isinstance(entry, dict):
114 normalised[name] = dict(entry)
115 elif isinstance(entry, int):
117 normalised[name] = {
"level": entry,
"color":
"default"}
119 normalised[name] = {
"level": entry,
"color":
"default"}
120 return normalised,
True
124 """OI-508 / FS-515: rewrite ``config_path`` in-place as a v2 config.
127 * Idempotent - if the file already declares ``_schema_version == "2.0"``
128 and carries ``DEBUG_LEVELS``, nothing is written.
129 * Writes ``<config_path>.bk`` before overwriting the original, so a
130 botched migration is recoverable.
131 * When ``dry_run`` is True, the target file is never touched and no
132 backup is produced; the returned dict is the post-migration shape.
134 Returns the (would-be) post-migration config dict so callers can
135 inspect / pretty-print the result.
137 func =
"migrate_config"
138 _dbg(
"OCT-DIAG", func,
"migrating the debug config", 3)
139 if not config_path.is_file():
140 raise FileNotFoundError(f
"Config not found: {config_path}")
141 original_text = config_path.read_text(encoding=
"utf-8")
142 config = json.loads(original_text)
143 if not isinstance(config, dict):
144 raise ValueError(
"Config root must be a JSON object")
148 config.get(
"_schema_version") == CURRENT_SCHEMA_VERSION
149 and "DEBUG_LEVELS" in config
156 new_config: dict = {
"_schema_version": CURRENT_SCHEMA_VERSION,
"DEBUG_LEVELS": domain_map}
157 for key, value
in config.items():
158 if key
in (
"_schema_version",
"DEBUG_LEVELS",
"domains"):
160 new_config[key] = value
165 backup_path = config_path.with_suffix(config_path.suffix +
".bk")
166 backup_path.write_text(original_text, encoding=
"utf-8")
167 config_path.write_text(json.dumps(new_config, indent=2) +
"\n", encoding=
"utf-8")
172 """Validate debug_config.json schema. Returns list of warnings/errors."""
173 func =
"validate_config"
174 _dbg(
"OCT-DIAG", func,
"validating the debug config", 3)
175 issues: list[str] = []
178 if not config_path.is_file():
179 issues.append(f
"ERROR: Config file not found: {config_path}")
183 text = config_path.read_text(encoding=
"utf-8")
185 issues.append(f
"ERROR: Cannot read {config_path}: {e}")
189 config = json.loads(text)
190 except json.JSONDecodeError
as e:
191 issues.append(f
"ERROR: Invalid JSON in {config_path}: {e}")
194 if not isinstance(config, dict):
195 issues.append(
"ERROR: Config root must be a JSON object")
199 version = config.get(
"_schema_version")
201 issues.append(
"WARNING: Missing '_schema_version' key")
209 "WARNING: 'domains' key is legacy v1 schema; run 'oct diag migrate-config' "
210 "to upgrade to v2 ('DEBUG_LEVELS')."
212 elif not domain_map
and "DEBUG_LEVELS" not in config
and "domains" not in config:
213 issues.append(
"WARNING: Missing 'DEBUG_LEVELS' key")
214 elif "DEBUG_LEVELS" in config
and not isinstance(config.get(
"DEBUG_LEVELS"), dict):
215 issues.append(
"ERROR: 'DEBUG_LEVELS' must be a JSON object")
216 for name, entry
in domain_map.items():
217 if not isinstance(entry, dict):
218 issues.append(f
"ERROR: Domain '{name}' must be a JSON object")
220 level = entry.get(
"level")
222 issues.append(f
"WARNING: Domain '{name}' missing 'level' key")
223 elif not isinstance(level, int)
or isinstance(level, bool)
or level
not in VALID_LEVELS:
224 issues.append(f
"ERROR: Domain '{name}' level must be 0-9, got: {level}")
227 settings = config.get(
"global_settings")
229 issues.append(
"WARNING: Missing 'global_settings' key")
230 elif not isinstance(settings, dict):
231 issues.append(
"ERROR: 'global_settings' must be a JSON object")
233 output_mode = settings.get(
"output_mode")
234 if output_mode
is not None and output_mode
not in (
"terminal",
"file",
"both",
"json"):
235 issues.append(f
"WARNING: 'output_mode' should be terminal|file|both|json, got: {output_mode!r}")
236 log_format = settings.get(
"log_format")
237 if log_format
is not None and log_format
not in VALID_LOG_FORMATS:
238 issues.append(f
"WARNING: 'log_format' should be text|jsonl, got: {log_format!r}")
239 mode = settings.get(
"mode")
240 if mode
is not None and mode
not in (
"dev",
"prod"):
241 issues.append(f
"WARNING: 'mode' should be dev|prod, got: {mode!r}")
247 """Return list of domain info dicts: [{name, level, color}, ...].
249 OI-508 / FS-515: reads via :func:`_get_debug_domain_map` so both v1
250 and v2 configs surface identical output.
255 for name, entry
in domain_map.items():
258 "level": entry.get(
"level",
"?"),
259 "color": entry.get(
"color",
"default"),
269 dry_run: bool =
False,
271 """Update a domain's debug level in debug_config.json.
273 OI-508 / FS-515: writes to whichever key the file actually uses
274 (``DEBUG_LEVELS`` on v2, ``domains`` on legacy v1) so an operator
275 can tweak a level without a full migration first.
277 OI-528: safe-edit semantics. A ``<config_path>.bk`` backup is written
278 before overwriting the original so a bad edit is recoverable via
279 :func:`restore_config`. When ``dry_run`` is True, neither the target
280 nor the backup is touched; the returned dict is the *would-be*
281 post-edit config shape so callers can preview the result.
284 _dbg(
"OCT-DIAG", func, f
"setting level for domain {domain}", 3)
285 if level
not in VALID_LEVELS:
286 raise ValueError(f
"Level must be 0-9, got: {level}")
289 if not config_path.is_file():
290 raise FileNotFoundError(f
"Config not found: {config_path}")
292 original_text = config_path.read_text(encoding=
"utf-8")
293 config = json.loads(original_text)
294 if "DEBUG_LEVELS" in config
and isinstance(config[
"DEBUG_LEVELS"], dict):
295 target_key =
"DEBUG_LEVELS"
296 elif "domains" in config
and isinstance(config[
"domains"], dict):
297 target_key =
"domains"
299 raise KeyError(
"Config has neither 'DEBUG_LEVELS' nor 'domains' keys")
301 domains = config[target_key]
302 if domain
not in domains:
303 raise KeyError(f
"Domain '{domain}' not found in config. Available: {', '.join(domains)}")
305 entry = domains[domain]
306 if isinstance(entry, dict):
307 entry[
"level"] = level
309 domains[domain] = {
"level": level,
"color":
"default"}
314 backup_path = config_path.with_suffix(config_path.suffix +
".bk")
315 backup_path.write_text(original_text, encoding=
"utf-8")
316 config_path.write_text(json.dumps(config, indent=2) +
"\n", encoding=
"utf-8")
321 """OI-528: restore ``debug_config.json`` from its ``.bk`` backup.
323 Returns the path that was restored. Raises :class:`FileNotFoundError`
324 when no backup exists so the CLI can surface a clean error instead of
328 backup_path = config_path.with_suffix(config_path.suffix +
".bk")
329 if not backup_path.is_file():
330 raise FileNotFoundError(f
"No backup found at {backup_path}")
331 config_path.write_text(backup_path.read_text(encoding=
"utf-8"), encoding=
"utf-8")
dict set_level(Path project_root, str domain, int level, *, bool dry_run=False)
list[dict] list_domains(Path project_root)
list[str] validate_config(Path project_root)
Path restore_config(Path project_root)
dict migrate_config(Path config_path, *, bool dry_run=False)
Path _find_config(Path project_root)
dict load_config(Path project_root)
tuple[dict, bool] _get_debug_domain_map(dict config)