#!/usr/bin/env python3 """Build generated reverse MOC views from canonical child frontmatter edges.""" from __future__ import annotations import argparse from collections import defaultdict import json from pathlib import Path import re import sys from typing import Any, Iterable, Mapping from contract_markdown import as_list, parse_frontmatter from fs_transaction import replace_many import vault_migrate DEFAULT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_RELATIONS = Path(__file__).resolve().parents[1] / "source/document-relations.json" DEFAULT_LAYOUT = Path("harness/source/vault-layout.json") RELATION_SCHEMA = "document-relations/v1" GEN_START = "" GEN_END = "" SAFE_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_RELATIVE_ROOT = re.compile(r"^(?:[a-zA-Z0-9._-]+/)*[a-zA-Z0-9._-]+$") CLUSTER_HEADING = re.compile(r"^##\s+.*(?:Cluster|묶음).*$", re.MULTILINE | re.IGNORECASE) FIELD_LINE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$") class MocError(ValueError): def __init__(self, code: str, message: str, path: str = "") -> None: self.code = code self.path = path super().__init__(message) def _authority_mapping(root: Path) -> dict[str, Any]: manifest_path = root / DEFAULT_LAYOUT if not manifest_path.is_file(): return {"mode": "compatibility", "legacy_to_canonical": {}} try: layout = json.loads(manifest_path.read_text(encoding="utf-8")) mode = layout.get("mode") migration = layout.get("migration_manifest") if isinstance(migration, str): migration_path = (root / migration).resolve() migration_path.relative_to(root) migration = json.loads(migration_path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: raise MocError("LAYOUT_SOURCE_ERROR", str(exc), DEFAULT_LAYOUT.as_posix()) from exc if mode not in {"compatibility", "shadow", "canonical"}: raise MocError("INVALID_LAYOUT_MODE", str(mode), DEFAULT_LAYOUT.as_posix()) if not isinstance(migration, dict) or migration.get("schema_version") != "vault-migration/v1": raise MocError("INVALID_MIGRATION_SCHEMA", "expected vault-migration/v1", DEFAULT_LAYOUT.as_posix()) entries = migration.get("entries") if not isinstance(entries, list): raise MocError("INVALID_MIGRATION_SCHEMA", "entries must be an array", DEFAULT_LAYOUT.as_posix()) mapping: dict[str, str] = {} reverse: set[str] = set() for index, item in enumerate(entries): if not isinstance(item, dict): raise MocError("INVALID_MIGRATION_ENTRY", str(index), DEFAULT_LAYOUT.as_posix()) legacy, canonical = item.get("legacy_path"), item.get("canonical_path") if not isinstance(legacy, str) or not isinstance(canonical, str): raise MocError("INVALID_MIGRATION_ENTRY", str(index), DEFAULT_LAYOUT.as_posix()) for value in (legacy, canonical): if Path(value).is_absolute() or ".." in Path(value).parts or "\\" in value: raise MocError("INVALID_MIGRATION_PATH", value, DEFAULT_LAYOUT.as_posix()) if legacy in mapping or canonical in reverse: raise MocError("DUPLICATE_MIGRATION_OWNER", f"entry {index}", DEFAULT_LAYOUT.as_posix()) mapping[legacy] = canonical reverse.add(canonical) return {"mode": mode, "legacy_to_canonical": mapping} def _authority_paths(root: Path, legacy_root: str, authority: Mapping[str, Any]) -> list[Path]: if authority["mode"] == "canonical": return sorted( root / canonical for legacy, canonical in authority["legacy_to_canonical"].items() if Path(legacy).is_relative_to(Path(legacy_root)) and legacy.endswith(".md") ) base = root / legacy_root return sorted(base.rglob("*.md")) if base.is_dir() else [] def _relative(path: Path, root: Path) -> str: return path.resolve().relative_to(root.resolve()).as_posix() def _logical_relative(path: Path, root: Path, authority: Mapping[str, Any]) -> str: """Render stable legacy wikilinks for manifest-owned canonical files.""" relative = _relative(path, root) if authority["mode"] != "canonical": return relative matches = [ legacy for legacy, canonical in authority["legacy_to_canonical"].items() if canonical == relative ] if len(matches) != 1: raise MocError("AMBIGUOUS_LOGICAL_PATH", f"expected one legacy owner: {relative}", relative) return matches[0] def _safe_root(value: Any, location: str) -> Path: if not isinstance(value, str) or not SAFE_RELATIVE_ROOT.fullmatch(value) or ".." in Path(value).parts: raise MocError("INVALID_RELATION_ROOT", f"invalid repo-relative root: {value!r}", location) return Path(value) def _marker_pair(marker: str) -> tuple[str, str]: return f"", f"" def _frontmatter_values(text: str, field: str) -> list[str]: """Return scalar/list frontmatter values, including bracket lists split over lines.""" parsed = as_list(parse_frontmatter(text).get(field)) if parsed and parsed != ["["]: return parsed lines = text.splitlines() if not lines or lines[0].strip() != "---": return [] for index, line in enumerate(lines[1:], 1): if line.strip() == "---": break match = FIELD_LINE.match(line) if not match or match.group(1) != field: continue raw = match.group(2).strip() if raw == "[": values: list[str] = [] for continuation in lines[index + 1 :]: token = continuation.strip() if token == "]": return values token = token.lstrip("- ").rstrip(",").strip().strip("'\"") if token: values.append(token) return [] return parsed return [] def _load_relations(path: Path) -> list[dict[str, Any]]: try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise MocError("RELATION_SOURCE_ERROR", str(exc), str(path)) from exc if not isinstance(document, dict) or document.get("schema_version") != RELATION_SCHEMA: raise MocError("INVALID_RELATION_SCHEMA", f"expected {RELATION_SCHEMA}", str(path)) raw_relations = document.get("relations") if not isinstance(raw_relations, list) or not raw_relations: raise MocError("INVALID_RELATION_SCHEMA", "relations must be a non-empty array", str(path)) result: list[dict[str, Any]] = [] seen: set[str] = set() allowed = { "id", "child_roots", "parent_field", "parent_roots", "marker", "marker_aliases", "parent_aliases", "reference_kind", "require_fields", "when", } for index, value in enumerate(raw_relations): location = f"{path}:relations[{index}]" if not isinstance(value, dict) or set(value) - allowed: raise MocError("INVALID_RELATION", "relation is not an object or has unknown fields", location) relation_id = value.get("id") marker = value.get("marker") parent_field = value.get("parent_field") if not isinstance(relation_id, str) or not SAFE_NAME.fullmatch(relation_id) or relation_id in seen: raise MocError("INVALID_RELATION_ID", f"invalid or duplicate relation id: {relation_id!r}", location) if not isinstance(marker, str) or not SAFE_NAME.fullmatch(marker): raise MocError("INVALID_RELATION_MARKER", f"invalid marker: {marker!r}", location) if not isinstance(parent_field, str) or not re.fullmatch(r"[A-Za-z_][\w-]*", parent_field): raise MocError("INVALID_PARENT_FIELD", f"invalid parent field: {parent_field!r}", location) if value.get("reference_kind", "slug") not in {"slug", "path"}: raise MocError("INVALID_REFERENCE_KIND", "reference_kind must be slug or path", location) for key in ("child_roots", "parent_roots"): roots = value.get(key) if not isinstance(roots, list) or not roots: raise MocError("INVALID_RELATION_ROOT", f"{key} must be non-empty", location) value[key] = [_safe_root(item, location).as_posix() for item in roots] aliases = value.get("marker_aliases", []) parent_aliases = value.get("parent_aliases", {}) requires = value.get("require_fields", []) if not isinstance(aliases, list) or any(not isinstance(item, str) or not SAFE_NAME.fullmatch(item) for item in aliases): raise MocError("INVALID_RELATION_MARKER", "marker_aliases contains an invalid marker", location) if not isinstance(requires, list) or any(not isinstance(item, str) for item in requires): raise MocError("INVALID_RELATION", "require_fields must be a string array", location) if ( not isinstance(parent_aliases, dict) or any( not isinstance(key, str) or not SAFE_NAME.fullmatch(key) or not isinstance(target, str) or not SAFE_NAME.fullmatch(target) for key, target in parent_aliases.items() ) ): raise MocError("INVALID_PARENT_ALIAS", "parent_aliases must map safe names to safe names", location) when = value.get("when") if when is not None: if ( not isinstance(when, dict) or set(when) != {"field", "operator"} or when.get("operator") not in {"empty", "nonempty"} or not isinstance(when.get("field"), str) ): raise MocError("INVALID_RELATION_CONDITION", "when must contain field and empty/nonempty operator", location) seen.add(relation_id) result.append(dict(value)) return result def _condition_matches(text: str, relation: Mapping[str, Any]) -> bool: condition = relation.get("when") if not condition: return True values = _frontmatter_values(text, str(condition["field"])) return bool(values) if condition["operator"] == "nonempty" else not values def _generated_block(marker: str, children: Iterable[str]) -> str: start, end = _marker_pair(marker) return "\n".join([start, *(f"- [[{child}]]" for child in sorted(set(children))), end]) def _replace_or_insert_marker( text: str, children: set[str], marker: str, rel: str, aliases: Iterable[str] = (), ) -> str: candidates = [marker, *aliases] located: list[tuple[str, int, int]] = [] for candidate in candidates: start_token, end_token = _marker_pair(candidate) starts = [match.start() for match in re.finditer(re.escape(start_token), text)] ends = [match.start() for match in re.finditer(re.escape(end_token), text)] if starts or ends: if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]: raise MocError("INVALID_GENERATED_BLOCK", f"{candidate} markers must form one ordered pair", rel) located.append((candidate, starts[0], ends[0] + len(end_token))) if len(located) > 1: raise MocError("DUPLICATE_GENERATED_VIEW", f"multiple marker variants exist for {marker}", rel) block = _generated_block(marker, children) if located: _candidate, start, end = located[0] return text[:start] + block + text[end:] heading = CLUSTER_HEADING.search(text) if heading: insert_at = text.find("\n", heading.end()) if insert_at < 0: return text + "\n\n" + block + "\n" return text[: insert_at + 1] + "\n" + block + "\n" + text[insert_at + 1 :] suffix = "" if text.endswith("\n") else "\n" return text + suffix + "\n## Cluster / 묶음\n\n" + block + "\n" def _replace_or_insert(text: str, children: set[str], rel: str) -> str: """Backward-compatible branches-view helper used by older callers/tests.""" return _replace_or_insert_marker(text, children, "branches", rel, ("children",)) def _resolve_parent( root: Path, roots: Iterable[str], reference: str, child_rel: str, aliases: Mapping[str, str] | None = None, authority: Mapping[str, Any] | None = None, reference_kind: str = "slug", ) -> Path: reference = (aliases or {}).get(reference, reference) active = authority or {"mode": "compatibility", "legacy_to_canonical": {}} if reference_kind == "slug": if not SAFE_NAME.fullmatch(reference): raise MocError("INVALID_PARENT_REFERENCE", f"invalid parent reference: {reference!r}", child_rel) candidates = [ candidate for parent_root in roots for candidate in _authority_paths(root, parent_root, active) if candidate.stem == reference ] else: candidate_path = Path(reference) if candidate_path.is_absolute() or ".." in candidate_path.parts or "\\" in reference: raise MocError("INVALID_PARENT_REFERENCE", f"invalid parent path: {reference!r}", child_rel) legacy = candidate_path.with_suffix(".md") if not candidate_path.suffix else candidate_path if not any(legacy.is_relative_to(Path(parent_root)) for parent_root in roots): raise MocError("INVALID_PARENT_REFERENCE", f"parent path is outside configured roots: {reference!r}", child_rel) resolved_relative = ( Path(active["legacy_to_canonical"].get(legacy.as_posix(), legacy.as_posix())) if active["mode"] == "canonical" else legacy ) candidates = [root / resolved_relative] matches = [candidate for candidate in candidates if candidate.is_file()] if len(matches) != 1: code = "MISSING_PARENT_HUB" if not matches else "AMBIGUOUS_PARENT_HUB" rendered = ", ".join(_relative(item, root) for item in candidates) raise MocError(code, f"expected one canonical parent for {reference}: {rendered}", child_rel) return matches[0] def build_updates( root: Path, relations_path: Path | None = None, ) -> tuple[dict[Path, str], dict[str, Any]]: root = root.resolve() source = relations_path or DEFAULT_RELATIONS if not source.is_absolute(): source = root / source relations = _load_relations(source.resolve(strict=True)) authority = _authority_mapping(root) expected: dict[tuple[Path, str], set[str]] = defaultdict(set) marker_aliases: dict[tuple[Path, str], set[str]] = defaultdict(set) relation_edges: dict[str, int] = defaultdict(int) skipped = 0 for relation in relations: for child_root_value in relation["child_roots"]: for child in _authority_paths(root, child_root_value, authority): if not child.is_file(): raise MocError( "MISSING_AUTHORITATIVE_DOCUMENT", "manifest owner is missing", child.relative_to(root).as_posix(), ) text = child.read_text(encoding="utf-8") if any(not _frontmatter_values(text, field) for field in relation.get("require_fields", [])): skipped += 1 continue if not _condition_matches(text, relation): continue references = _frontmatter_values(text, relation["parent_field"]) if not references: continue child_link = Path(_logical_relative(child, root, authority)).with_suffix("").as_posix() resolved_parents = { _resolve_parent( root, relation["parent_roots"], reference, child_link, relation.get("parent_aliases"), authority, relation.get("reference_kind", "slug"), ) for reference in references } for parent in resolved_parents: key = (parent, relation["marker"]) expected[key].add(child_link) marker_aliases[key].update(relation.get("marker_aliases", [])) relation_edges[relation["id"]] += 1 # Existing generated views remain managed even when their last child disappears. marker_names = [relation["marker"], *relation.get("marker_aliases", [])] for parent_root_value in relation["parent_roots"]: for hub in _authority_paths(root, parent_root_value, authority): if not hub.is_file(): raise MocError( "MISSING_AUTHORITATIVE_DOCUMENT", "manifest owner is missing", hub.relative_to(root).as_posix(), ) hub_text = hub.read_text(encoding="utf-8") if any(any(token in hub_text for token in _marker_pair(name)) for name in marker_names): key = (hub, relation["marker"]) expected.setdefault(key, set()) marker_aliases[key].update(relation.get("marker_aliases", [])) by_hub: dict[Path, list[tuple[str, set[str], set[str]]]] = defaultdict(list) for (hub, marker), children in expected.items(): by_hub[hub].append((marker, children, marker_aliases[(hub, marker)])) updates: dict[Path, str] = {} indexed: list[str] = [] view_count = 0 for hub in sorted(by_hub, key=lambda path: _relative(path, root)): original = hub.read_text(encoding="utf-8") rendered = original rel = _relative(hub, root) for marker, children, aliases in sorted(by_hub[hub], key=lambda item: item[0]): rendered = _replace_or_insert_marker(rendered, children, marker, rel, sorted(aliases)) view_count += 1 indexed.append(rel) if rendered != original: updates[hub] = rendered return updates, { "mode": authority["mode"], "namespace": "canonical" if authority["mode"] == "canonical" else "legacy", "canonical_edges": sum(relation_edges.values()), "structured_children": relation_edges.get("branch-to-branch", 0) + relation_edges.get("branch-to-project", 0), "skipped_legacy": skipped, "relation_edges": dict(sorted(relation_edges.items())), "indexed_views": view_count, "indexed_hubs": indexed, "changed_hubs": [_relative(path, root) for path in sorted(updates)], } def _emit(document: dict[str, Any]) -> None: json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") def _parse_args(argv: list[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--relations", type=Path, default=DEFAULT_RELATIONS) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--check", action="store_true", help="report drift without writing") mode.add_argument("--apply", action="store_true", help="atomically replace changed hubs") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) try: root = args.root.resolve(strict=True) updates, stats = build_updates(root, args.relations) if args.apply and updates: encoded = {path: text.encode("utf-8") for path, text in updates.items()} expanded, _authority = vault_migrate.expand_authoritative_changes( root, encoded, relations_path=args.relations, ) replace_many(expanded) status = "DRIFT" if args.check and updates else "UPDATED" if updates else "CURRENT" _emit({"schema_version": "moc-indexer-result/v2", "status": status, **stats}) return 1 if args.check and updates else 0 except (MocError, vault_migrate.MigrationError, OSError, UnicodeError) as exc: _emit({ "schema_version": "moc-indexer-result/v2", "status": "FAIL", "error": { "code": getattr(exc, "code", "IO_ERROR"), "path": getattr(exc, "path", ""), "message": str(exc), }, }) return 2 if __name__ == "__main__": raise SystemExit(main())