init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate compatibility, shadow, and canonical project-first vault layouts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from contract_markdown import parse_frontmatter
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_MANIFEST = Path("harness/source/vault-layout.json")
|
||||
CONTENT_ROOTS = ("raw", "wiki")
|
||||
MODES = {"compatibility", "shadow", "canonical"}
|
||||
HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
WIKILINK = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
|
||||
FENCE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
|
||||
INLINE_CODE = re.compile(r"(`+)(.*?)\1")
|
||||
|
||||
|
||||
class LayoutContractError(ValueError):
|
||||
def __init__(self, code: str, message: str, location: str = "") -> None:
|
||||
self.code = code
|
||||
self.location = location
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _finding(code: str, path: str, detail: str = "") -> dict[str, str]:
|
||||
item = {"code": code, "path": path}
|
||||
if detail:
|
||||
item["detail"] = detail
|
||||
return item
|
||||
|
||||
|
||||
def _lexical_absolute(path: Path) -> Path:
|
||||
"""Return an absolute repository path without following redirects."""
|
||||
|
||||
return Path(os.path.abspath(path))
|
||||
|
||||
|
||||
def _load_embedded_or_path(root: Path, value: Any, expected_schema: str, findings: list[dict[str, str]], location: str) -> Mapping[str, Any] | None:
|
||||
document = value
|
||||
if isinstance(value, str):
|
||||
candidate = (root / value).resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
document = json.loads(candidate.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
findings.append(_finding("CUTOVER_SOURCE_ERROR", value, str(exc)))
|
||||
return None
|
||||
if not isinstance(document, dict) or document.get("schema_version") != expected_schema:
|
||||
findings.append(_finding("INVALID_CUTOVER_SCHEMA", location, f"expected {expected_schema}"))
|
||||
return None
|
||||
return document
|
||||
|
||||
|
||||
def _safe_repo_path(root: Path, value: Any, findings: list[dict[str, str]], location: str) -> Path | None:
|
||||
if not isinstance(value, str) or not value or "\\" in value or Path(value).is_absolute():
|
||||
findings.append(_finding("INVALID_MIGRATION_PATH", location, str(value)))
|
||||
return None
|
||||
resolved = _lexical_absolute(root / value)
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
findings.append(_finding("INVALID_MIGRATION_PATH", location, str(value)))
|
||||
return None
|
||||
if resolved.is_symlink():
|
||||
try:
|
||||
resolved.resolve().relative_to(root)
|
||||
except ValueError:
|
||||
findings.append(_finding("INVALID_MIGRATION_PATH", location, "symlink target escapes repository"))
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def _content_files(root: Path, assignments: Mapping[str, str]) -> set[Path]:
|
||||
files: set[Path] = set()
|
||||
for source in assignments:
|
||||
base = root / source
|
||||
if base.is_dir():
|
||||
files.update(
|
||||
path for path in base.rglob("*")
|
||||
if path.is_file() and path.name != ".gitkeep"
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def _migration_entries(
|
||||
root: Path,
|
||||
manifest: Mapping[str, Any],
|
||||
findings: list[dict[str, str]],
|
||||
) -> tuple[dict[Path, tuple[Path, str]], dict[Path, Path]]:
|
||||
entries = manifest.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
findings.append(_finding("INVALID_CUTOVER_SCHEMA", "migration_manifest.entries", "must be an array"))
|
||||
return {}, {}
|
||||
by_legacy: dict[Path, tuple[Path, str]] = {}
|
||||
by_canonical: dict[Path, Path] = {}
|
||||
for index, item in enumerate(entries):
|
||||
location = f"migration_manifest.entries[{index}]"
|
||||
if not isinstance(item, dict) or set(item) != {"legacy_path", "canonical_path", "sha256"}:
|
||||
findings.append(_finding("INVALID_MIGRATION_ENTRY", location))
|
||||
continue
|
||||
legacy = _safe_repo_path(root, item.get("legacy_path"), findings, f"{location}.legacy_path")
|
||||
canonical = _safe_repo_path(root, item.get("canonical_path"), findings, f"{location}.canonical_path")
|
||||
digest = item.get("sha256")
|
||||
if not isinstance(digest, str) or not HEX_SHA256.fullmatch(digest):
|
||||
findings.append(_finding("INVALID_MIGRATION_HASH", location, str(digest)))
|
||||
continue
|
||||
if legacy is None or canonical is None:
|
||||
continue
|
||||
if legacy in by_legacy:
|
||||
findings.append(_finding("DUPLICATE_LEGACY_OWNER", legacy.relative_to(root).as_posix()))
|
||||
if canonical in by_canonical:
|
||||
findings.append(_finding("DUPLICATE_CANONICAL_OWNER", canonical.relative_to(root).as_posix()))
|
||||
by_legacy[legacy] = (canonical, digest)
|
||||
by_canonical[canonical] = legacy
|
||||
return by_legacy, by_canonical
|
||||
|
||||
|
||||
def _validate_links(
|
||||
root: Path,
|
||||
files: Iterable[Path],
|
||||
findings: list[dict[str, str]],
|
||||
*,
|
||||
namespace: Iterable[Path] | None = None,
|
||||
) -> None:
|
||||
namespace_items = list(namespace if namespace is not None else root.rglob("*"))
|
||||
all_files = [path for path in namespace_items if path.is_file() and ".git" not in path.parts]
|
||||
namespace_paths = {path.resolve() for path in all_files}
|
||||
by_basename: dict[str, list[Path]] = {}
|
||||
by_name: dict[str, list[Path]] = {}
|
||||
for path in all_files:
|
||||
by_basename.setdefault(path.stem, []).append(path)
|
||||
by_name.setdefault(path.name, []).append(path)
|
||||
for source in sorted(path for path in set(files) if path.suffix == ".md"):
|
||||
lines: list[str] = []
|
||||
fence_char = ""
|
||||
fence_length = 0
|
||||
for line in source.read_text(encoding="utf-8").splitlines():
|
||||
if fence_char:
|
||||
if re.fullmatch(rf"[ \t]{{0,3}}{re.escape(fence_char)}{{{fence_length},}}[ \t]*", line):
|
||||
fence_char, fence_length = "", 0
|
||||
continue
|
||||
match = FENCE.match(line)
|
||||
if match:
|
||||
token = match.group(1)
|
||||
if not fence_char:
|
||||
fence_char, fence_length = token[0], len(token)
|
||||
continue
|
||||
lines.append(INLINE_CODE.sub("", line))
|
||||
text = "\n".join(lines).replace(r"\|", "|")
|
||||
for target_value in WIKILINK.findall(text):
|
||||
target = target_value.strip()
|
||||
if not target:
|
||||
continue
|
||||
if "/" not in target:
|
||||
matches = (
|
||||
by_name.get(Path(target).name, [])
|
||||
if Path(target).suffix
|
||||
else by_basename.get(Path(target).stem, [])
|
||||
)
|
||||
if not matches:
|
||||
findings.append(_finding("BROKEN_LINK", source.relative_to(root).as_posix(), target))
|
||||
elif len(matches) > 1:
|
||||
findings.append(_finding("AMBIGUOUS_BASENAME_LINK", source.relative_to(root).as_posix(), target))
|
||||
continue
|
||||
candidate = root / target
|
||||
if not candidate.is_file():
|
||||
markdown_candidate = Path(str(candidate) + ".md")
|
||||
if markdown_candidate.is_file():
|
||||
candidate = markdown_candidate
|
||||
if not candidate.is_file():
|
||||
findings.append(_finding("BROKEN_LINK", source.relative_to(root).as_posix(), target))
|
||||
elif namespace is not None and candidate.resolve() not in namespace_paths:
|
||||
# STALE_AUTHORITY_LINK 은 "권한 밖 사본을 가리키는 링크"를 잡는다. shadow 모드
|
||||
# (namespace=legacy|external)에서 canonical(vault/) 경로를 *미리* 가리키면 발화한다.
|
||||
# canonical 모드(namespace=canonical|external)에서는 [[raw/…]]·[[wiki/…]] 레거시
|
||||
# 경로가 호환 심링크를 통해 canonical 로 resolve() 되므로 여기 걸리지 않는다 —
|
||||
# 이는 의도된 것이다(raw/·wiki/ 는 영구 호환 별칭). 회귀 방지:
|
||||
# test_vault_migrate.test_canonical_symlink_preserves_escaped_alias_wikilink_without_stale_authority.
|
||||
findings.append(_finding("STALE_AUTHORITY_LINK", source.relative_to(root).as_posix(), target))
|
||||
|
||||
|
||||
def _validate_cutover(
|
||||
root: Path,
|
||||
mode: str,
|
||||
vault_root: Path,
|
||||
assignments: Mapping[str, str],
|
||||
manifest: Mapping[str, Any],
|
||||
findings: list[dict[str, str]],
|
||||
) -> dict[str, int]:
|
||||
migration = _load_embedded_or_path(root, manifest.get("migration_manifest"), "vault-migration/v1", findings, "migration_manifest")
|
||||
rollback = _load_embedded_or_path(root, manifest.get("rollback_mapping"), "vault-rollback/v1", findings, "rollback_mapping")
|
||||
if migration is None or rollback is None:
|
||||
return {"migration_entries": 0, "canonical_files": 0}
|
||||
by_legacy, by_canonical = _migration_entries(root, migration, findings)
|
||||
legacy_files = _content_files(root, assignments)
|
||||
canonical_files = {
|
||||
path for path in vault_root.rglob("*")
|
||||
if path.is_file() and path.name != ".gitkeep" and path != vault_root / "README.md"
|
||||
}
|
||||
if set(by_legacy) != legacy_files:
|
||||
for path in sorted(legacy_files - set(by_legacy)):
|
||||
findings.append(_finding("MIGRATION_ENTRY_MISSING", path.relative_to(root).as_posix()))
|
||||
for path in sorted(set(by_legacy) - legacy_files):
|
||||
findings.append(_finding("MIGRATION_LEGACY_EXTRA", path.relative_to(root).as_posix()))
|
||||
if set(by_canonical) != canonical_files:
|
||||
for path in sorted(canonical_files - set(by_canonical)):
|
||||
findings.append(_finding("CANONICAL_OWNER_MISSING", path.relative_to(root).as_posix()))
|
||||
for path in sorted(set(by_canonical) - canonical_files):
|
||||
findings.append(_finding("MIGRATION_CANONICAL_MISSING", path.relative_to(root).as_posix()))
|
||||
|
||||
for legacy, (canonical, expected_hash) in sorted(by_legacy.items()):
|
||||
if not canonical.is_file():
|
||||
continue
|
||||
if mode == "shadow":
|
||||
# cutover 검증 단계에서만 콘텐츠 해시를 대조한다. 이 단계의 목적은
|
||||
# "이관이 내용을 그대로 옮겼는가"를 증명하는 것이다.
|
||||
observed_hash = hashlib.sha256(canonical.read_bytes()).hexdigest()
|
||||
if observed_hash != expected_hash:
|
||||
findings.append(_finding("MIGRATION_HASH_MISMATCH", canonical.relative_to(root).as_posix(), f"expected {expected_hash}, observed {observed_hash}"))
|
||||
# canonical 모드에서는 문서가 계속 편집되는 것이 정상이므로 해시를 고정하지 않는다.
|
||||
# 이 모드에서 manifest 는 legacy→canonical 매핑과 rollback 근거로만 쓰인다
|
||||
# (매핑 완전성은 위 MIGRATION_ENTRY_MISSING / CANONICAL_OWNER_MISSING 이 검사한다).
|
||||
#
|
||||
# shadow 불변식: legacy·canonical 은 서로 *독립된 실파일* 바이트 동일 미러다.
|
||||
# 둘 중 하나라도 심링크면 is_file()/read_bytes() 가 상대를 따라가 항상 '동일'로
|
||||
# 읽혀 drift 를 못 잡는다(symlink-blind). 그래서 심링크를 먼저 loud 하게 잡는다.
|
||||
if legacy.is_symlink() or canonical.is_symlink():
|
||||
findings.append(_finding(
|
||||
"SHADOW_MIRROR_SYMLINK", legacy.relative_to(root).as_posix(),
|
||||
"shadow 미러는 독립 실파일이어야 함 — 심링크는 byte-identical 검사를 무력화한다"))
|
||||
elif not legacy.is_file():
|
||||
findings.append(_finding("SHADOW_SOURCE_MISSING", legacy.relative_to(root).as_posix()))
|
||||
elif legacy.read_bytes() != canonical.read_bytes():
|
||||
findings.append(_finding("SHADOW_MIRROR_DRIFT", canonical.relative_to(root).as_posix()))
|
||||
elif mode == "canonical" and legacy.is_symlink():
|
||||
expected_target = canonical.resolve()
|
||||
observed_target = legacy.resolve()
|
||||
if observed_target != expected_target:
|
||||
findings.append(
|
||||
_finding(
|
||||
"INVALID_COMPATIBILITY_SYMLINK",
|
||||
legacy.relative_to(root).as_posix(),
|
||||
f"expected target {canonical.relative_to(root).as_posix()}",
|
||||
)
|
||||
)
|
||||
elif mode == "canonical" and legacy.is_file():
|
||||
if legacy.read_bytes() == canonical.read_bytes():
|
||||
findings.append(_finding("OLD_NEW_FULL_CONTENT_DUPLICATE", legacy.relative_to(root).as_posix()))
|
||||
if legacy.suffix == ".md":
|
||||
canonical_value = parse_frontmatter(legacy.read_text(encoding="utf-8")).get("canonical_path")
|
||||
expected_path = canonical.relative_to(root).as_posix()
|
||||
if canonical_value != expected_path:
|
||||
findings.append(_finding("INVALID_COMPATIBILITY_STUB", legacy.relative_to(root).as_posix(), f"canonical_path must be {expected_path}"))
|
||||
else:
|
||||
findings.append(
|
||||
_finding(
|
||||
"NON_MARKDOWN_LEGACY_REMAINS",
|
||||
legacy.relative_to(root).as_posix(),
|
||||
"canonical mode requires a symlink redirect for non-Markdown legacy assets",
|
||||
)
|
||||
)
|
||||
|
||||
rollback_entries = rollback.get("entries")
|
||||
rollback_pairs: dict[Path, Path] = {}
|
||||
if not isinstance(rollback_entries, list):
|
||||
findings.append(_finding("INVALID_CUTOVER_SCHEMA", "rollback_mapping.entries", "must be an array"))
|
||||
else:
|
||||
for index, item in enumerate(rollback_entries):
|
||||
location = f"rollback_mapping.entries[{index}]"
|
||||
if not isinstance(item, dict) or set(item) != {"canonical_path", "legacy_path"}:
|
||||
findings.append(_finding("INVALID_ROLLBACK_ENTRY", location))
|
||||
continue
|
||||
canonical = _safe_repo_path(root, item.get("canonical_path"), findings, f"{location}.canonical_path")
|
||||
legacy = _safe_repo_path(root, item.get("legacy_path"), findings, f"{location}.legacy_path")
|
||||
if canonical is not None and legacy is not None:
|
||||
if canonical in rollback_pairs:
|
||||
findings.append(_finding("DUPLICATE_ROLLBACK_MAPPING", canonical.relative_to(root).as_posix()))
|
||||
rollback_pairs[canonical] = legacy
|
||||
for canonical, legacy in by_canonical.items():
|
||||
if rollback_pairs.get(canonical) != legacy:
|
||||
findings.append(_finding("ROLLBACK_MAPPING_MISSING", canonical.relative_to(root).as_posix()))
|
||||
for canonical in rollback_pairs.keys() - by_canonical.keys():
|
||||
findings.append(_finding("ROLLBACK_MAPPING_EXTRA", canonical.relative_to(root).as_posix()))
|
||||
|
||||
all_repository_files = {
|
||||
path for path in root.rglob("*")
|
||||
if path.is_file() and ".git" not in path.parts
|
||||
}
|
||||
external_files = all_repository_files - legacy_files - canonical_files
|
||||
link_namespace = (legacy_files if mode == "shadow" else canonical_files) | external_files
|
||||
_validate_links(root, canonical_files, findings, namespace=link_namespace)
|
||||
return {"migration_entries": len(by_legacy), "canonical_files": len(canonical_files)}
|
||||
|
||||
|
||||
def resolve_authority(
|
||||
root: Path,
|
||||
manifest_path: Path = DEFAULT_MANIFEST,
|
||||
*,
|
||||
require_clean: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve the active write authority and fail closed on invalid layout state."""
|
||||
root = root.resolve(strict=True)
|
||||
manifest_file = manifest_path if manifest_path.is_absolute() else root / manifest_path
|
||||
try:
|
||||
result = check_layout(root, manifest_path)
|
||||
manifest_bytes = manifest_file.read_bytes()
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise LayoutContractError("LAYOUT_IO_ERROR", str(exc), str(manifest_path)) from exc
|
||||
if require_clean and result.get("status") != "PASS":
|
||||
codes = ",".join(sorted({item.get("code", "UNKNOWN") for item in result.get("findings", [])}))
|
||||
raise LayoutContractError("LAYOUT_NOT_READY", codes or "layout validation failed", str(manifest_path))
|
||||
mode = result.get("mode")
|
||||
write_roots = result.get("write_roots")
|
||||
if mode not in MODES or not isinstance(write_roots, list) or not write_roots:
|
||||
raise LayoutContractError("INVALID_WRITE_AUTHORITY", "active mode has no valid write roots", str(manifest_path))
|
||||
return {
|
||||
"mode": mode,
|
||||
"authority": result.get("authority"),
|
||||
"write_roots": list(write_roots),
|
||||
"manifest_path": manifest_file.relative_to(root).as_posix(),
|
||||
"manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def enforce_write_paths(root: Path, paths: Iterable[Path], authority: Mapping[str, Any]) -> None:
|
||||
"""Require every repository write to live under the active authoritative roots."""
|
||||
root = _lexical_absolute(root)
|
||||
allowed = [_lexical_absolute(root / value) for value in authority.get("write_roots", [])]
|
||||
for path in paths:
|
||||
resolved = _lexical_absolute(path)
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise LayoutContractError("WRITE_OUTSIDE_REPOSITORY", str(path), str(path)) from exc
|
||||
if not any(resolved == prefix or prefix in resolved.parents for prefix in allowed):
|
||||
relative = resolved.relative_to(root).as_posix()
|
||||
raise LayoutContractError(
|
||||
"WRITE_ROOT_VIOLATION",
|
||||
f"{relative} is outside active write roots {authority.get('write_roots', [])}",
|
||||
relative,
|
||||
)
|
||||
|
||||
|
||||
def check_layout(root: Path, manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]:
|
||||
root = root.resolve()
|
||||
manifest_file = manifest_path if manifest_path.is_absolute() else root / manifest_path
|
||||
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
findings: list[dict[str, str]] = []
|
||||
if manifest.get("schema_version") != "vault-layout/v1":
|
||||
findings.append(_finding("INVALID_LAYOUT_SCHEMA", str(manifest_path)))
|
||||
mode = manifest.get("mode")
|
||||
if mode not in MODES:
|
||||
findings.append(_finding("INVALID_LAYOUT_MODE", str(manifest_path)))
|
||||
|
||||
vault_root = root / str(manifest.get("vault_root", "vault"))
|
||||
assignments: dict[str, str] = {}
|
||||
areas = manifest.get("areas")
|
||||
if not isinstance(areas, dict):
|
||||
findings.append(_finding("INVALID_LAYOUT_SCHEMA", "areas"))
|
||||
areas = {}
|
||||
for area, sources in areas.items():
|
||||
target = vault_root / area
|
||||
if not target.is_dir():
|
||||
findings.append(_finding("MISSING_VAULT_AREA", target.relative_to(root).as_posix()))
|
||||
if not isinstance(sources, list):
|
||||
findings.append(_finding("INVALID_LAYOUT_SCHEMA", f"areas.{area}"))
|
||||
continue
|
||||
for raw_source in sources:
|
||||
source = Path(str(raw_source)).as_posix().rstrip("/")
|
||||
if source in assignments:
|
||||
findings.append(_finding("DUPLICATE_LAYOUT_OWNER", source, f"{assignments[source]},{area}"))
|
||||
assignments[source] = area
|
||||
if not (root / source).is_dir():
|
||||
findings.append(_finding("MISSING_COMPATIBILITY_SOURCE", source))
|
||||
if source == "harness" or source.startswith("harness/"):
|
||||
findings.append(_finding("HARNESS_INSIDE_VAULT", source))
|
||||
|
||||
actual = {
|
||||
path.relative_to(root).as_posix()
|
||||
for content_root in CONTENT_ROOTS
|
||||
if (root / content_root).is_dir()
|
||||
for path in (root / content_root).iterdir()
|
||||
if path.is_dir()
|
||||
}
|
||||
for path in sorted(actual - set(assignments)):
|
||||
findings.append(_finding("UNASSIGNED_CONTENT_ROOT", path))
|
||||
for required in ("harness/source", "harness/adapters", "harness/runtime", "harness/tests"):
|
||||
if not (root / required).is_dir():
|
||||
findings.append(_finding("MISSING_HARNESS_AREA", required))
|
||||
|
||||
write_roots = manifest.get("write_roots")
|
||||
if not isinstance(write_roots, dict) or set(write_roots) != MODES:
|
||||
findings.append(_finding("INVALID_WRITE_ROOTS", "write_roots", "all three modes are required"))
|
||||
else:
|
||||
expected = [Path(str(manifest.get("vault_root", "vault"))).as_posix()] if mode == "canonical" else ["raw", "wiki"]
|
||||
observed = write_roots.get(mode)
|
||||
if observed != expected:
|
||||
findings.append(_finding("INVALID_AUTHORITATIVE_WRITE_ROOT", f"write_roots.{mode}", f"expected {expected}, observed {observed}"))
|
||||
|
||||
cutover_stats = {"migration_entries": 0, "canonical_files": 0}
|
||||
if mode in {"shadow", "canonical"}:
|
||||
cutover_stats = _validate_cutover(root, mode, vault_root, assignments, manifest, findings)
|
||||
|
||||
findings.sort(key=lambda item: (item["path"], item["code"], item.get("detail", "")))
|
||||
return {
|
||||
"schema_version": "layout-check-result/v2",
|
||||
"status": "PASS" if not findings else "FAIL",
|
||||
"mode": mode or "",
|
||||
"authority": "vault" if mode == "canonical" else "legacy",
|
||||
"write_roots": write_roots.get(mode, []) if isinstance(write_roots, dict) and mode in MODES else [],
|
||||
"assigned_sources": len(assignments),
|
||||
"discovered_content_roots": len(actual),
|
||||
**cutover_stats,
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
||||
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
result = check_layout(args.root.resolve(strict=True), args.manifest)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
result = {
|
||||
"schema_version": "layout-check-result/v2",
|
||||
"status": "ERROR",
|
||||
"findings": [_finding("LAYOUT_IO_ERROR", str(exc))],
|
||||
}
|
||||
exit_code = 2
|
||||
else:
|
||||
exit_code = 0 if result["status"] == "PASS" else 1
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user