109 """Attach ``source`` (format/validate/lint/diff) + ``hash-preimage`` (§10)."""
111 source_parser = roadmap_commands.add_parser(
112 "source", help=
"Validate/format/lint/diff the AuthoredSource roadmap tree."
114 source_commands = source_parser.add_subparsers(dest=
"source_command")
116 format_parser = source_commands.add_parser(
117 "format", help=
"Canonicalise source records (§3.2); --check to verify only."
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)
125 validate_parser = source_commands.add_parser(
126 "validate", help=
"Run the §5.3 steps 1–6 validation chain (fail-fast, exit 1)."
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)
134 lint_parser = source_commands.add_parser(
135 "lint", help=
"Non-failing style warnings (exit 0 even with warnings)."
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)
143 diff_parser = source_commands.add_parser(
144 "diff", help=
"Semantic diff of two source trees (exit 0 same / 1 differs / 2 error)."
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)
155 migrate_parser = source_commands.add_parser(
157 help=
"Destructively migrate source records to --to-schema-version (§4): "
158 "requires a clean git worktree; journalled + auto-rollback; no --source-dir.",
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).",
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)
169 preimage_parser = roadmap_commands.add_parser(
170 "hash-preimage", help=
"Print the canonical preimage a §3.2 hash is taken over."
172 preimage_parser.add_argument(
"registry", nargs=
"?")
173 preimage_parser.add_argument(
"--source-dir", dest=
"source_dir")
174 preimage_parser.add_argument(
176 choices=[
"source-tree",
"raw-source-tree",
"semantic",
"logical-state"],
177 default=
"source-tree",
179 preimage_parser.add_argument(
"--format", choices=[
"json",
"yaml"], default=
"json")
180 preimage_parser.set_defaults(handler=_cmd_hash_preimage)
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)."""
208 "message": str(error),
209 "records": error.records,
212 if isinstance(error, SourceScanError):
213 envelope[
"scan_error"] = error.scan_error
215 print(json.dumps(envelope, indent=2, sort_keys=
True))
217 print(f
"source failed [{error.code}]: {error}")
253 if exit_code
is not None:
255 format_warnings: list[dict] = []
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",
265 format_warnings.append(warning)
267 print(f
"warning: {warning['message']}", file=sys.stderr)
272 dirty = [line[3:].split(
" -> ")[-1]
for line
in status.splitlines()
if line.strip()]
274 f
"resolved source root {root} has uncommitted changes; refusing a "
275 "destructive format (no rollback point)",
277 records=sorted(dirty),
278 hint=
"commit or stash changes under the resolved source root before 'format'",
283 records = scan_source_tree(root)
284 changed = format_source_tree(records, check=args.check)
285 except SourceError
as error:
289 return 2
if isinstance(error, SourceScanError)
else 1
290 payload = {
"ok":
True,
"checked": args.check,
"changed": changed,
"count": len(records)}
292 payload[
"warnings"] = format_warnings
294 print(json.dumps(payload, indent=2, sort_keys=
True))
296 verb =
"non-canonical" if args.check
else "reformatted"
297 print(f
"source format ok ({len(changed)} {verb}, {len(records)} records)")
304 if exit_code
is not None:
306 raw_hash:
"str | None" =
None
307 src_hash:
"str | None" =
None
309 records = scan_source_tree(root)
310 raw_hash = raw_source_tree_hash(root)
311 categorized = _categorize(records)
312 format_source_tree(records, check=
True)
313 src_hash = source_hash(records)
314 validate_cross_record(categorized)
315 except SourceError
as error:
319 envelope = build_envelope(error, raw_source_tree_hash=raw_hash, source_hash=src_hash)
321 return exit_code_for(error)
323 print(json.dumps({
"ok":
True}, indent=2, sort_keys=
True))
325 print(f
"source validate ok ({len(records)} records)")
332 if exit_code
is not None:
334 raw_hash:
"str | None" =
None
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)
342 return exit_code_for(error)
345 print(json.dumps({
"ok":
True,
"warnings": warnings}, indent=2, sort_keys=
True))
347 for warning
in warnings:
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)")
355 """The first required lint (§3.2): a record whose ``track_codes`` reference an
356 inactive track. Non-failing; deterministically ordered."""
358 track.track_code
for track
in categorized.get(
"tracks", [])
359 if not getattr(track,
"active",
True)
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:
374 "rule":
"inactive_track_membership",
376 "message": f
"{owner_id!r} references inactive track {code!r}",
377 "severity":
"warning",
379 warnings.sort(key=
lambda warning: (warning[
"path"], warning[
"message"]))
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":
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)
424 full, keyobj =
_logical_key(scanned.kind, scanned, model)
425 units[full] =
_unit(_DIFF_KIND[scanned.kind], keyobj, scanned.rel_path, model)
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"])
456 name
for name
in field_names
457 if unit_a[
"fields"].get(name) != unit_b[
"fields"].get(name)
459 if fields
or unit_a[
"path"] != unit_b[
"path"]:
461 "kind": unit_b[
"kind"],
"key": unit_b[
"key"],
462 "old_path": unit_a[
"path"],
"new_path": unit_b[
"path"],
"fields": fields,
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}
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
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
523 func =
"_cmd_source_migrate"
527 target = validate_target_semver(args.to_schema_version)
528 except MigrateError
as error:
534 except WorkerRegistryError
as error:
535 print(str(error), file=sys.stderr)
537 lock_path = workspace_root /
".option_c" /
"orchestration" /
"regenerate.lock"
541 handle = lock.acquire(lock_path)
542 except RegenerateError
as error:
544 return exit_code_for(error)
547 payload = run_migration(
548 source_root=source_root, workspace_root=workspace_root, target=target,
549 provider=provider, quiet=args.quiet,
551 except (SourceError, RegenerateError)
as error:
552 envelope = build_envelope(
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),
562 return exit_code_for(error)
566 _dbg(
"OC_CHAT.MAIN", func,
"migrate ok", 2)
590 if exit_code
is not None:
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)
598 with backend.connect()
as conn:
599 preimage = hashing.hash_preimage(
"logical-state", conn=conn)
601 records = scan_source_tree(root)
602 if args.projection ==
"semantic":
606 (record, load_record(record)[0])
607 for record
in records
609 preimage = hashing.hash_preimage(
"semantic", loaded=loaded)
611 preimage = hashing.hash_preimage(
"source-tree", records=records)
612 except SourceError
as error:
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=
"")
618 print(json.dumps(preimage, indent=2, sort_keys=
True))