oc_chat
Loading...
Searching...
No Matches
source.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/cli/source.py
4
5# OCT-LINT: disable=no_hardcoded_secrets reason="CLI help strings and projection names are not secrets"
6# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/source/test_cli.py"
7"""
8CLI commands for the registry AuthoredSource format (Plan 142 P1.3 + 142j R5).
9
10Purpose
11-------
12Expose the §10 read-only ``source`` surface: ``format [--check]`` (the §3.2
13idempotent canonicaliser, with the 142j write-mode clean-tree precondition),
14``validate`` (the §5.3 steps 1–6 chain, fail-fast), ``lint`` (non-failing style
15warnings), ``diff`` (a semantic diff over parsed/canonical models), and
16``hash-preimage`` (the §3.2 debug preimage). The destructive ``migrate`` command
17is Plan 142m.
18
19Responsibilities
20----------------
21- Resolve the source tree (default ``<workspace>/oc_chat/roadmap/source``).
22- Run each read-only command, emitting the §8 ``build_envelope`` on failure and
23 each command's own success payload otherwise.
24- Enforce the write-mode clean-tree precondition + the worker discovery guard.
25
26Diagnostics
27-----------
28Domain: OC_CHAT.MAIN
29Levels:
30 L2 - CLI lifecycle
31 L3 - command argument summaries
32 L4 - subcommand dispatch details
33
34Contracts
35---------
36- Read-only except ``source format`` (without ``--check``) which rewrites
37 non-canonical records in place (refused on a dirty resolved-source tree).
38- Non-zero exit emits the §10 envelope; ``diff`` owns 0/1/2 (exit 1 = "differs").
39
40Dependencies
41------------
42- argparse (CLI parsing)
43- oc_chat.registry.discovery (workspace/registry resolution + the worker guard)
44- oc_diagnostics (_dbg structured logger)
45- oc_chat.registry.backends.sqlite (SQLiteBackend connection for the logical-state hash preimage)
46- oc_chat.registry.regenerate.cross_record (validate_cross_record, the cross-record step of validate)
47- oc_chat.registry.regenerate.envelope (RegenerateError, SRC_TREE_DIRTY, build_envelope and exit_code_for)
48- oc_chat.registry.regenerate.lock (acquire and release the shared regenerate lock around migrate)
49- oc_chat.registry.regenerate.pipeline (_categorize, the record load and schema steps of validate and lint)
50- oc_chat.registry.source.canonical (format_source_tree canonicaliser and load_record for diff and hash-preimage)
51- oc_chat.registry.source.errors (SourceError and SourceScanError for the error envelopes and exit codes)
52- oc_chat.registry.source.hashing (raw-source-tree and source hashes plus the hash-preimage projections)
53- oc_chat.registry.source.scanner (scan_source_tree over the resolved source tree)
54- oc_chat.registry.source_migrations.errors (MigrateError for the target schema version refusal)
55- oc_chat.registry.source_migrations.runner (production migration provider, run_migration and target semver validation)
56- yaml (YAML output for hash-preimage)
57- git (not imported; run as a subprocess for rev-parse and status --porcelain, the clean-tree precondition of write-mode source format)
58"""
59from __future__ import annotations
60
61import argparse
62import json
63from pathlib import Path
64import subprocess
65import sys
66
67import yaml
68
69from oc_diagnostics import _dbg
70
71from oc_chat.registry.backends.sqlite import SQLiteBackend
72from oc_chat.registry.discovery import discover_registry_location, WorkerRegistryError
73from oc_chat.registry.regenerate import lock
74from oc_chat.registry.regenerate.cross_record import validate_cross_record
76 RegenerateError,
77 SRC_TREE_DIRTY,
78 build_envelope,
79 exit_code_for,
80)
81from oc_chat.registry.regenerate.pipeline import _categorize
82from oc_chat.registry.source import hashing
83from oc_chat.registry.source.canonical import format_source_tree, load_record
85 SourceError,
86 SourceScanError,
87 SourceValidationError,
88)
89from oc_chat.registry.source.hashing import raw_source_tree_hash, source_hash
90from oc_chat.registry.source.scanner import scan_source_tree
91from oc_chat.registry.source_migrations.errors import MigrateError
93 production_migration_provider,
94 run_migration,
95 validate_target_semver,
96)
97
98_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
99
100#: scanner-kind -> the diff pseudo-record kind label (promoted/inline sub_phase
101#: both normalize to "sub_phase"; §5.6 inline↔promoted moves are path-changes).
102_DIFF_KIND = {
103 "manifest": "manifest", "era": "era", "track": "track", "phase": "phase",
104 "roadmap_item": "item", "version": "version", "framing": "framing", "note": "note",
105}
106
107
108def add_source_subcommands(roadmap_commands: argparse._SubParsersAction) -> None:
109 """Attach ``source`` (format/validate/lint/diff) + ``hash-preimage`` (§10)."""
110
111 source_parser = roadmap_commands.add_parser(
112 "source", help="Validate/format/lint/diff the AuthoredSource roadmap tree."
113 )
114 source_commands = source_parser.add_subparsers(dest="source_command")
115
116 format_parser = source_commands.add_parser(
117 "format", help="Canonicalise source records (§3.2); --check to verify only."
118 )
119 format_parser.add_argument("registry", nargs="?")
120 format_parser.add_argument("--source-dir", dest="source_dir")
121 format_parser.add_argument("--check", action="store_true")
122 format_parser.add_argument("--json", action="store_true")
123 format_parser.set_defaults(handler=_cmd_source_format)
124
125 validate_parser = source_commands.add_parser(
126 "validate", help="Run the §5.3 steps 1–6 validation chain (fail-fast, exit 1)."
127 )
128 validate_parser.add_argument("registry", nargs="?")
129 validate_parser.add_argument("--source-dir", dest="source_dir")
130 validate_parser.add_argument("--json", action="store_true")
131 validate_parser.add_argument("--quiet", action="store_true")
132 validate_parser.set_defaults(handler=_cmd_source_validate)
133
134 lint_parser = source_commands.add_parser(
135 "lint", help="Non-failing style warnings (exit 0 even with warnings)."
136 )
137 lint_parser.add_argument("registry", nargs="?")
138 lint_parser.add_argument("--source-dir", dest="source_dir")
139 lint_parser.add_argument("--json", action="store_true")
140 lint_parser.add_argument("--quiet", action="store_true")
141 lint_parser.set_defaults(handler=_cmd_source_lint)
142
143 diff_parser = source_commands.add_parser(
144 "diff", help="Semantic diff of two source trees (exit 0 same / 1 differs / 2 error)."
145 )
146 diff_parser.add_argument("a")
147 diff_parser.add_argument("b")
148 diff_parser.add_argument("--json", action="store_true")
149 diff_parser.add_argument("--quiet", action="store_true")
150 diff_parser.set_defaults(handler=_cmd_source_diff)
151
152 # Plan 142m: the DESTRUCTIVE migrate leaf. Deliberately takes NO --source-dir
153 # (Sonnet5-R07-P1-2) — it operates ONLY on the discovered workspace's own
154 # source tree, so the lock + journal never split from the tree being rewritten.
155 migrate_parser = source_commands.add_parser(
156 "migrate",
157 help="Destructively migrate source records to --to-schema-version (§4): "
158 "requires a clean git worktree; journalled + auto-rollback; no --source-dir.",
159 )
160 migrate_parser.add_argument("registry", nargs="?")
161 migrate_parser.add_argument(
162 "--to-schema-version", dest="to_schema_version", required=True,
163 help="Target source-record semver (MAJOR.MINOR.PATCH).",
164 )
165 migrate_parser.add_argument("--json", action="store_true")
166 migrate_parser.add_argument("--quiet", action="store_true")
167 migrate_parser.set_defaults(handler=_cmd_source_migrate)
168
169 preimage_parser = roadmap_commands.add_parser(
170 "hash-preimage", help="Print the canonical preimage a §3.2 hash is taken over."
171 )
172 preimage_parser.add_argument("registry", nargs="?")
173 preimage_parser.add_argument("--source-dir", dest="source_dir")
174 preimage_parser.add_argument(
175 "--projection",
176 choices=["source-tree", "raw-source-tree", "semantic", "logical-state"],
177 default="source-tree",
178 )
179 preimage_parser.add_argument("--format", choices=["json", "yaml"], default="json")
180 preimage_parser.set_defaults(handler=_cmd_hash_preimage)
181
182
183# --- shared helpers ---------------------------------------------------------
184def _source_dir(args: argparse.Namespace) -> Path:
185 if args.source_dir:
186 return Path(args.source_dir)
187 location = discover_registry_location(registry_path=args.registry)
188 return Path(location.workspace_root) / "oc_chat" / "roadmap" / "source"
189
190
191def _resolve_root_or_exit(args: argparse.Namespace) -> "tuple[Path | None, int | None]":
192 """Resolve the source dir; on the R7 worker-guard failure (a registry-discovery
193 error, NOT a §8 envelope) print the stderr token and return exit 2."""
194 try:
195 return _source_dir(args), None
196 except WorkerRegistryError as error:
197 print(str(error), file=sys.stderr)
198 return None, 2
199
200
201def _emit_envelope(error: SourceError, as_json: bool) -> None:
202 """The pre-142j compact envelope (no §8 hash keys) — kept for the EXISTING
203 ``format``/``hash-preimage`` error paths (unchanged; only the new SRC_TREE_DIRTY
204 path uses ``build_envelope``, R7 narrow-scope)."""
205 envelope = {
206 "ok": False,
207 "code": error.code,
208 "message": str(error),
209 "records": error.records,
210 "hint": error.hint,
211 }
212 if isinstance(error, SourceScanError):
213 envelope["scan_error"] = error.scan_error
214 if as_json:
215 print(json.dumps(envelope, indent=2, sort_keys=True))
216 else:
217 print(f"source failed [{error.code}]: {error}")
218
219
220def _print_error_envelope(envelope: dict, as_json: bool) -> None:
221 """Print the full §8 ``build_envelope`` (with the hash-nullability keys)."""
222 if as_json:
223 print(json.dumps(envelope, indent=2, sort_keys=True))
224 else:
225 print(f"source failed [{envelope.get('code')}]: {envelope.get('message')}")
226
227
228def _git_status_porcelain(root: Path) -> "str | None":
229 """Return ``git status --porcelain`` limited to ``root``'s subtree, or ``None``
230 if ``root`` is not inside a git worktree (R7 write-mode clean-tree precondition;
231 scoped to the resolved root, so unrelated edits elsewhere never block)."""
232 try:
233 inside = subprocess.run(
234 ["git", "rev-parse", "--is-inside-work-tree"],
235 cwd=str(root), capture_output=True, text=True, check=False,
236 )
237 except (OSError, ValueError):
238 return None
239 if inside.returncode != 0 or inside.stdout.strip() != "true":
240 return None
241 result = subprocess.run(
242 ["git", "status", "--porcelain", "."],
243 cwd=str(root), capture_output=True, text=True, check=False,
244 )
245 if result.returncode != 0:
246 return None
247 return result.stdout
248
249
250# --- format (write-mode clean-tree precondition + narrow SRC_TREE_DIRTY) -----
251def _cmd_source_format(args: argparse.Namespace) -> int:
252 root, exit_code = _resolve_root_or_exit(args)
253 if exit_code is not None:
254 return exit_code
255 format_warnings: list[dict] = []
256 if not args.check: # R7: the precondition gates write-mode only, never --check
257 status = _git_status_porcelain(root)
258 if status is None:
259 warning = {
260 "rule": "clean_tree_precondition",
261 "message": f"{root} is not inside a git worktree; skipping the clean-tree "
262 "precondition (no rollback point is enforced for this format)",
263 "severity": "warning",
264 }
265 format_warnings.append(warning)
266 if not args.json:
267 print(f"warning: {warning['message']}", file=sys.stderr)
268 elif status.strip():
269 # R7 narrow-scope: the SRC_TREE_DIRTY refusal uses build_envelope (full
270 # §8 key set) + an EXPLICIT exit 2 (NOT exit_code_for — a SRC_ code that
271 # exits 2, in _EXIT_BY_CODE; the OTHER format error paths stay as-is).
272 dirty = [line[3:].split(" -> ")[-1] for line in status.splitlines() if line.strip()]
273 error = SourceError(
274 f"resolved source root {root} has uncommitted changes; refusing a "
275 "destructive format (no rollback point)",
276 code=SRC_TREE_DIRTY,
277 records=sorted(dirty),
278 hint="commit or stash changes under the resolved source root before 'format'",
279 )
280 _print_error_envelope(build_envelope(error), args.json)
281 return 2
282 try:
283 records = scan_source_tree(root)
284 changed = format_source_tree(records, check=args.check)
285 except SourceError as error:
286 # EXISTING paths, unchanged (do NOT convert to exit_code_for — would flip
287 # SourceScanError's exit 2→1, DeepSeek-R11-NP1-1).
288 _emit_envelope(error, args.json)
289 return 2 if isinstance(error, SourceScanError) else 1
290 payload = {"ok": True, "checked": args.check, "changed": changed, "count": len(records)}
291 if format_warnings:
292 payload["warnings"] = format_warnings
293 if args.json:
294 print(json.dumps(payload, indent=2, sort_keys=True))
295 else:
296 verb = "non-canonical" if args.check else "reformatted"
297 print(f"source format ok ({len(changed)} {verb}, {len(records)} records)")
298 return 0
299
300
301# --- validate (§5.3 steps 1–6, fail-fast) -----------------------------------
302def _cmd_source_validate(args: argparse.Namespace) -> int:
303 root, exit_code = _resolve_root_or_exit(args)
304 if exit_code is not None:
305 return exit_code
306 raw_hash: "str | None" = None
307 src_hash: "str | None" = None
308 try:
309 records = scan_source_tree(root) # step 1 (+ scanner casefold/notes)
310 raw_hash = raw_source_tree_hash(root)
311 categorized = _categorize(records) # steps 2–3 (load + local schema + device)
312 format_source_tree(records, check=True) # step 4 canonicalisation check
313 src_hash = source_hash(records) # step 5
314 validate_cross_record(categorized) # step 6 (incl. framing + item collision)
315 except SourceError as error:
316 # §10 hash-nullability falls out of the variable state (each hash is set
317 # only after its step succeeds): scan → both null; parse/canon → raw only;
318 # cross-record → raw + source.
319 envelope = build_envelope(error, raw_source_tree_hash=raw_hash, source_hash=src_hash)
320 _print_error_envelope(envelope, args.json)
321 return exit_code_for(error)
322 if args.json:
323 print(json.dumps({"ok": True}, indent=2, sort_keys=True))
324 elif not args.quiet:
325 print(f"source validate ok ({len(records)} records)")
326 return 0
327
328
329# --- lint (non-failing style warnings) --------------------------------------
330def _cmd_source_lint(args: argparse.Namespace) -> int:
331 root, exit_code = _resolve_root_or_exit(args)
332 if exit_code is not None:
333 return exit_code
334 raw_hash: "str | None" = None
335 try:
336 records = scan_source_tree(root)
337 raw_hash = raw_source_tree_hash(root)
338 categorized = _categorize(records)
339 except SourceError as error:
340 envelope = build_envelope(error, raw_source_tree_hash=raw_hash)
341 _print_error_envelope(envelope, args.json)
342 return exit_code_for(error)
343 warnings = _lint_inactive_tracks(categorized)
344 if args.json:
345 print(json.dumps({"ok": True, "warnings": warnings}, indent=2, sort_keys=True))
346 else:
347 for warning in warnings: # warnings are diagnostics, never --quiet-suppressed
348 print(f"{warning['rule']}: {warning['path']}: {warning['message']}")
349 if not warnings and not args.quiet:
350 print(f"source lint ok (0 warnings, {len(records)} records)")
351 return 0
352
353
354def _lint_inactive_tracks(categorized: dict) -> list[dict]:
355 """The first required lint (§3.2): a record whose ``track_codes`` reference an
356 inactive track. Non-failing; deterministically ordered."""
357 inactive = {
358 track.track_code for track in categorized.get("tracks", [])
359 if not getattr(track, "active", True)
360 }
361 owners: list[tuple[str, list, str]] = []
362 for phase, _body in categorized.get("phases", []):
363 owners.append((phase.id, getattr(phase, "track_codes", []) or [], f"phases/{phase.id}.md"))
364 for item, _body in categorized.get("items", []):
365 owners.append((item.id, getattr(item, "track_codes", []) or [],
366 f"items/{item.track_kind}/{item.id}.md"))
367 for scanned, item, _body in categorized.get("promoted", []):
368 owners.append((item.id, getattr(item, "track_codes", []) or [], scanned.rel_path))
369 warnings: list[dict] = []
370 for owner_id, codes, location in owners:
371 for code in codes:
372 if code in inactive:
373 warnings.append({
374 "rule": "inactive_track_membership",
375 "path": location,
376 "message": f"{owner_id!r} references inactive track {code!r}",
377 "severity": "warning",
378 })
379 warnings.sort(key=lambda warning: (warning["path"], warning["message"]))
380 return warnings
381
382
383# --- diff (semantic diff over parsed/canonical models) ----------------------
384def _cmd_source_diff(args: argparse.Namespace) -> int:
385 # diff is EXEMPT from exit_code_for — exit 1 means "differs" (normal), not
386 # "error"; a scan/validation error returns 2 (usage/error class).
387 try:
388 units_a = _diff_units(Path(args.a))
389 units_b = _diff_units(Path(args.b))
390 except SourceError as error:
391 _print_error_envelope(build_envelope(error), args.json)
392 return 2
393 result = _compare_units(units_a, units_b)
394 differs = bool(result["added"] or result["removed"] or result["changed"])
395 if args.json:
396 print(json.dumps(result, indent=2, sort_keys=True))
397 elif differs:
398 _print_diff_human(result)
399 elif not args.quiet:
400 print("source diff: no differences")
401 return 1 if differs else 0
402
403
404def _diff_units(root: Path) -> dict:
405 """Return ``{full_key_tuple: unit}`` over the parsed models — every entity plus
406 each inline sub_phase / promoted item as a normalized ``sub_phase`` pseudo-record
407 (same key ``{identity, parent_phase}``), so an inline↔promoted move is a path
408 change, not add+remove (§5.6)."""
409 units: dict[tuple, dict] = {}
410 for scanned in scan_source_tree(root):
411 model, _body = load_record(scanned)
412 if scanned.kind == "phase":
413 full, keyobj = _logical_key("phase", scanned, model)
414 units[full] = _unit("phase", keyobj, scanned.rel_path, model)
415 for sub in getattr(model, "sub_phases", []) or []:
416 full_s = ("sub_phase", sub.id, None, model.id)
417 keyobj_s = {"identity": sub.id, "parent_phase": model.id}
418 units[full_s] = _unit("sub_phase", keyobj_s, f"{scanned.rel_path}#{sub.id}", sub)
419 elif scanned.kind == "promoted_item":
420 full_s = ("sub_phase", model.id, None, scanned.phase_code)
421 keyobj_s = {"identity": model.id, "parent_phase": scanned.phase_code}
422 units[full_s] = _unit("sub_phase", keyobj_s, scanned.rel_path, model)
423 else:
424 full, keyobj = _logical_key(scanned.kind, scanned, model)
425 units[full] = _unit(_DIFF_KIND[scanned.kind], keyobj, scanned.rel_path, model)
426 return units
427
428
429def _logical_key(kind: str, scanned, model) -> "tuple[tuple, dict]":
430 """Per-kind logical key (Codex-R10-P1): track→track_code; version→(route, slug);
431 manifest→singleton; framing/note/era/item/phase→id. Returns
432 ``(hashable_full_key, emitted_key_object)`` (absent fields omitted)."""
433 if kind == "track":
434 return ("track", model.track_code, None, None), {"identity": model.track_code}
435 if kind == "version":
436 route = scanned.changelog_kind
437 return ("version", model.slug, route, None), {"identity": model.slug, "route": route}
438 if kind == "manifest":
439 return ("manifest", "manifest", None, None), {"identity": "manifest"}
440 return (kind, model.id, None, None), {"identity": model.id}
441
442
443def _unit(kind: str, keyobj: dict, path: str, model) -> dict:
444 return {"kind": kind, "key": keyobj, "path": path, "fields": model.model_dump(mode="json")}
445
446
447def _compare_units(units_a: dict, units_b: dict) -> dict:
448 keys_a, keys_b = set(units_a), set(units_b)
449 added = [_locate(units_b[key]) for key in keys_b - keys_a]
450 removed = [_locate(units_a[key]) for key in keys_a - keys_b]
451 changed: list[dict] = []
452 for key in keys_a & keys_b:
453 unit_a, unit_b = units_a[key], units_b[key]
454 field_names = set(unit_a["fields"]) | set(unit_b["fields"])
455 fields = sorted(
456 name for name in field_names
457 if unit_a["fields"].get(name) != unit_b["fields"].get(name)
458 )
459 if fields or unit_a["path"] != unit_b["path"]:
460 changed.append({
461 "kind": unit_b["kind"], "key": unit_b["key"],
462 "old_path": unit_a["path"], "new_path": unit_b["path"], "fields": fields,
463 })
464 added.sort(key=_diff_sort_key)
465 removed.sort(key=_diff_sort_key)
466 changed.sort(key=_diff_sort_key)
467 return {"added": added, "removed": removed, "changed": changed}
468
469
470def _locate(unit: dict) -> dict:
471 return {"kind": unit["kind"], "key": unit["key"], "path": unit["path"]}
472
473
474def _diff_sort_key(record: dict) -> tuple:
475 key = record["key"]
476 return (
477 record["kind"], key["identity"], key.get("route") or "", key.get("parent_phase") or "",
478 record.get("path", record.get("old_path", "")),
479 )
480
481
482def _print_diff_human(result: dict) -> None:
483 for record in result["removed"]:
484 print(f"- {record['kind']} {record['key']} ({record['path']})")
485 for record in result["added"]:
486 print(f"+ {record['kind']} {record['key']} ({record['path']})")
487 for record in result["changed"]:
488 moved = "" if record["old_path"] == record["new_path"] else \
489 f" {record['old_path']} -> {record['new_path']}"
490 fields = f" fields={record['fields']}" if record["fields"] else ""
491 print(f"~ {record['kind']} {record['key']}{moved}{fields}")
492
493
494# --- migrate (the destructive Plan 142m engine) -----------------------------
496 """The migration-discovery seam (private, non-CLI). Default = PRODUCTION
497 discovery over ``registry/source_migrations/``. Tests monkeypatch THIS symbol
498 (the consuming namespace) to inject fakes; there is NO ``--migrations-package``
499 flag — a visible flag pointing migrate at arbitrary code is a production
500 footgun. Production CLI always uses the default."""
501 return production_migration_provider
502
503
504def _resolve_migrate_paths(args: argparse.Namespace) -> "tuple[Path, Path]":
505 """Resolve (source_root, workspace_root) through THIS module's binding of
506 ``discover_registry_location`` (the consuming-namespace seam the tests patch).
507 migrate takes NO ``--source-dir``: root = the discovered workspace's own source
508 tree, and the shared lock + journal key to the SAME workspace root."""
509 location = discover_registry_location(registry_path=args.registry)
510 workspace_root = Path(location.workspace_root)
511 source_root = (workspace_root / "oc_chat" / "roadmap" / "source").resolve()
512 return source_root, workspace_root
513
514
515def _cmd_source_migrate(args: argparse.Namespace) -> int:
516 """The ``roadmap source migrate`` handler — pipeline steps 0-2 (semver shape,
517 root resolution, the shared lock) + the runner (steps 3-13). Mirrors
518 ``regenerate/pipeline`` lock choreography: step 0 in its OWN pre-lock
519 try/except; a separate try wrapping the runner whose
520 ``except (SourceError, RegenerateError)`` catches BOTH the migrate refusals and
521 the lock's ``REGEN_LOCK_HELD``, with a ``finally`` that releases the lock
522 UNCONDITIONALLY."""
523 func = "_cmd_source_migrate"
524 # Step 0: validate the --to-schema-version SHAPE (own pre-lock try/except;
525 # all-null hashes; never argparse usage-exit).
526 try:
527 target = validate_target_semver(args.to_schema_version)
528 except MigrateError as error:
529 _print_error_envelope(build_envelope(error), args.json)
530 return 2
531 # Step 1: resolve the source root (worker-guard -> exit 2 + stderr, NO envelope).
532 try:
533 source_root, workspace_root = _resolve_migrate_paths(args)
534 except WorkerRegistryError as error:
535 print(str(error), file=sys.stderr)
536 return 2
537 lock_path = workspace_root / ".option_c" / "orchestration" / "regenerate.lock"
538 # Step 2: acquire the SHARED regenerate lock (its own try/except — the
539 # REGEN_LOCK_HELD RegenerateError is raised by lock.acquire itself).
540 try:
541 handle = lock.acquire(lock_path)
542 except RegenerateError as error:
543 _print_error_envelope(build_envelope(error), args.json)
544 return exit_code_for(error)
545 try:
546 provider = _migration_registry_factory()
547 payload = run_migration(
548 source_root=source_root, workspace_root=workspace_root, target=target,
549 provider=provider, quiet=args.quiet,
550 )
551 except (SourceError, RegenerateError) as error:
552 envelope = build_envelope(
553 error,
554 raw_source_tree_hash=getattr(error, "raw_source_tree_hash", None),
555 source_hash=getattr(error, "source_hash", None),
556 rollback=getattr(error, "rollback", None),
557 journal_path=getattr(error, "journal_path", None),
558 remaining_paths=getattr(error, "remaining_paths", None),
559 journal_unresolved=getattr(error, "journal_unresolved", None),
560 )
561 _print_error_envelope(envelope, args.json)
562 return exit_code_for(error)
563 finally:
564 lock.release(handle)
565 _print_migrate_success(payload, args.json, args.quiet)
566 _dbg("OC_CHAT.MAIN", func, "migrate ok", 2)
567 return 0
568
569
570def _print_migrate_success(payload: dict, as_json: bool, quiet: bool) -> None:
571 """Emit the migrate SUCCESS payload. ``--quiet`` suppresses ONLY the non-JSON
572 human success line; it NEVER suppresses the ``--json`` payload or the
573 journal-unresolved stderr warning."""
574 if payload.get("journal_unresolved"):
575 print(f"warning: {payload.get('hint')}", file=sys.stderr) # never --quiet-suppressed
576 if as_json:
577 print(json.dumps(payload, indent=2, sort_keys=True))
578 elif not quiet:
579 migrated = payload["records_migrated"]
580 print(
581 f"source migrate ok ({len(migrated)} migrated, "
582 f"{payload['records_unchanged']} unchanged, "
583 f"target {payload['target_schema_version']})"
584 )
585
586
587# --- hash-preimage (unchanged §3.2 debug surface) ---------------------------
588def _cmd_hash_preimage(args: argparse.Namespace) -> int:
589 root, exit_code = _resolve_root_or_exit(args)
590 if exit_code is not None:
591 return exit_code
592 try:
593 if args.projection == "raw-source-tree":
594 preimage = hashing.hash_preimage("raw-source-tree", root=root)
595 elif args.projection == "logical-state":
596 location = discover_registry_location(registry_path=args.registry)
597 backend = SQLiteBackend(location.registry_path)
598 with backend.connect() as conn:
599 preimage = hashing.hash_preimage("logical-state", conn=conn)
600 else:
601 records = scan_source_tree(root)
602 if args.projection == "semantic":
603 # [OP-5] promoted parity: include promoted records in the
604 # semantic projection (the earlier model-gap filter is removed).
605 loaded = [
606 (record, load_record(record)[0])
607 for record in records
608 ]
609 preimage = hashing.hash_preimage("semantic", loaded=loaded)
610 else:
611 preimage = hashing.hash_preimage("source-tree", records=records)
612 except SourceError as error:
613 _emit_envelope(error, args.format == "json")
614 return 2 if isinstance(error, SourceScanError) else 1
615 if args.format == "yaml":
616 print(yaml.safe_dump(preimage, sort_keys=True, allow_unicode=True), end="")
617 else:
618 print(json.dumps(preimage, indent=2, sort_keys=True))
619 return 0
int _cmd_source_format(argparse.Namespace args)
Definition source.py:251
list[dict] _lint_inactive_tracks(dict categorized)
Definition source.py:354
int _cmd_source_lint(argparse.Namespace args)
Definition source.py:330
int _cmd_source_diff(argparse.Namespace args)
Definition source.py:384
"str | None" _git_status_porcelain(Path root)
Definition source.py:228
None _print_diff_human(dict result)
Definition source.py:482
"tuple[tuple, dict]" _logical_key(str kind, scanned, model)
Definition source.py:429
dict _locate(dict unit)
Definition source.py:470
int _cmd_hash_preimage(argparse.Namespace args)
Definition source.py:588
"tuple[Path, Path]" _resolve_migrate_paths(argparse.Namespace args)
Definition source.py:504
None _print_migrate_success(dict payload, bool as_json, bool quiet)
Definition source.py:570
int _cmd_source_validate(argparse.Namespace args)
Definition source.py:302
Path _source_dir(argparse.Namespace args)
Definition source.py:184
"tuple[Path | None, int | None]" _resolve_root_or_exit(argparse.Namespace args)
Definition source.py:191
dict _unit(str kind, dict keyobj, str path, model)
Definition source.py:443
dict _diff_units(Path root)
Definition source.py:404
None _print_error_envelope(dict envelope, bool as_json)
Definition source.py:220
tuple _diff_sort_key(dict record)
Definition source.py:474
None _emit_envelope(SourceError error, bool as_json)
Definition source.py:201
int _cmd_source_migrate(argparse.Namespace args)
Definition source.py:515
dict _compare_units(dict units_a, dict units_b)
Definition source.py:447
None add_source_subcommands(argparse._SubParsersAction roadmap_commands)
Definition source.py:108