Files
llm-wiki/harness/runtime/moc_indexer.py
T

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())