452 lines
20 KiB
Python
452 lines
20 KiB
Python
#!/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 = "<!-- GENERATED: branches:start -->"
|
|
GEN_END = "<!-- GENERATED: branches: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"<!-- GENERATED: {marker}:start -->", f"<!-- GENERATED: {marker}:end -->"
|
|
|
|
|
|
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())
|