Files
nyaterm/scripts/ci/check_architecture.py
Kang e967621060 feat(agents): introduce new crates for file management and remote operations
- Added `nyaterm-files` for local and remote file services, including SFTP and shell file backends.
- Introduced `nyaterm-remote-ops` for system metrics, process management, and Docker operations.
- Updated `AGENTS.md` to reflect the new crates and their responsibilities.
- Ensured that the new crates remain independent of GPUI and desktop presentation types.
- Updated `Cargo.toml` and `Cargo.lock` to include new dependencies and workspace members.
2026-11-05 22:04:39 +08:00

226 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Reject new violations of NyaTerm's crate and runtime architecture rules."""
from __future__ import annotations
import json
import re
import sys
import tomllib
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ALLOWLIST_PATH = ROOT / "scripts" / "ci" / "architecture_allowlist.json"
RUST_FILES = tuple((ROOT / "crates").glob("*/src/**/*.rs"))
def relative(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def matching_counts(pattern: re.Pattern[str], files: tuple[Path, ...]) -> Counter[str]:
counts: Counter[str] = Counter()
for path in files:
text = path.read_text(encoding="utf-8")
count = len(pattern.findall(text))
if count:
counts[relative(path)] = count
return counts
def load_allowlist() -> dict[str, list[dict[str, object]]]:
document = json.loads(ALLOWLIST_PATH.read_text(encoding="utf-8"))
if not isinstance(document, dict):
raise ValueError("architecture allowlist root must be an object")
for category, entries in document.items():
if not isinstance(entries, list):
raise ValueError(f"allowlist category {category!r} must be a list")
seen: set[str] = set()
for entry in entries:
path = entry.get("path")
reason = entry.get("reason")
maximum = entry.get("max_count")
if not isinstance(path, str) or not path or path in seen:
raise ValueError(f"{category}: every path must be unique and non-empty")
if not isinstance(reason, str) or not reason.strip():
raise ValueError(f"{category}: {path} must explain why it is allowed")
if not isinstance(maximum, int) or maximum < 1:
raise ValueError(f"{category}: {path} max_count must be a positive integer")
seen.add(path)
return document
def check_budget(
category: str,
actual: Counter[str],
allowlist: dict[str, list[dict[str, object]]],
) -> list[str]:
errors: list[str] = []
entries = {entry["path"]: entry for entry in allowlist.get(category, [])}
for path, count in actual.items():
entry = entries.get(path)
if entry is None:
errors.append(f"{category}: unapproved occurrence in {path} ({count})")
elif count > entry["max_count"]:
errors.append(
f"{category}: {path} has {count}, exceeding baseline {entry['max_count']}"
)
for path in entries.keys() - actual.keys():
errors.append(f"{category}: stale allowlist entry for {path}")
return errors
def dependency_errors() -> list[str]:
errors: list[str] = []
dependency = re.compile(r"(?m)^\s*(gpui|gpui_platform|gpui-kit)\s*=")
low_level = (
"nyaterm-core",
"nyaterm-transport",
"nyaterm-files",
"nyaterm-recording",
"nyaterm-remote-ops",
"nyaterm-terminal",
"nyaterm-store",
"nyaterm-sync",
"nyaterm-remote-desktop",
"nyaterm-plugin-api",
"nyaterm-plugin-host",
)
for crate in low_level:
manifest = ROOT / "crates" / crate / "Cargo.toml"
if dependency.search(manifest.read_text(encoding="utf-8")):
errors.append(f"crate_boundary: {crate} must remain independent of GPUI")
desktop_manifest = (ROOT / "crates" / "nyaterm-desktop" / "Cargo.toml").read_text(
encoding="utf-8"
)
if re.search(r"(?m)^\s*(gpui-kit|gpui_component)\s*=", desktop_manifest):
errors.append("crate_boundary: nyaterm-desktop must use nyaterm-ui wrappers")
workspace = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8"))
workspace_dependencies = workspace["workspace"]["dependencies"]
for crate, forbidden in (
("nyaterm-core", {"nyaterm-sync", "nyaterm-store", "nyaterm-desktop", "nyaterm-files", "nyaterm-recording", "nyaterm-remote-ops", "nyaterm-transport"}),
("nyaterm-transport", {"nyaterm-files", "nyaterm-recording", "nyaterm-remote-ops", "nyaterm-desktop", "nyaterm-store", "nyaterm-ui"}),
("nyaterm-files", {"nyaterm-recording", "nyaterm-remote-ops", "nyaterm-desktop", "nyaterm-store", "nyaterm-ui"}),
("nyaterm-remote-ops", {"nyaterm-files", "nyaterm-recording", "nyaterm-desktop", "nyaterm-store", "nyaterm-ui"}),
("nyaterm-recording", {"nyaterm-files", "nyaterm-remote-ops", "nyaterm-transport", "nyaterm-desktop", "nyaterm-store", "nyaterm-ui"}),
("nyaterm-sync", {
"nyaterm-store", "nyaterm-desktop", "nyaterm-transport", "nyaterm-ui",
"reqwest", "zed-reqwest", "hyper", "ureq", "isahc", "surf",
"redb", "rusqlite", "sqlx", "tokio",
}),
("nyaterm-plugin-api", {
"nyaterm-core", "nyaterm-desktop", "nyaterm-store", "nyaterm-transport",
"nyaterm-plugin-host", "nyaterm-ui", "nyaterm-terminal-gpui",
"nyaterm-files", "nyaterm-recording", "nyaterm-remote-ops",
}),
("nyaterm-plugin-host", {"nyaterm-desktop", "nyaterm-transport", "nyaterm-ui", "nyaterm-terminal-gpui", "nyaterm-files", "nyaterm-recording", "nyaterm-remote-ops"}),
):
document = tomllib.loads((ROOT / "crates" / crate / "Cargo.toml").read_text(encoding="utf-8"))
for package in dependency_packages(document, workspace_dependencies):
wrong_sync_direction = (
crate == "nyaterm-sync"
and package.startswith("nyaterm-")
and package != "nyaterm-core"
)
if wrong_sync_direction or package in forbidden or package == "gpui" or package.startswith("gpui-"):
errors.append(f"crate_boundary: {crate} must not depend on {package}")
return errors
def dependency_packages(document: dict, workspace_dependencies: dict):
"""Resolve aliases in ordinary, build, dev and target-specific dependencies."""
for name, value in document.items():
if name in ("dependencies", "dev-dependencies", "build-dependencies"):
for alias, specification in value.items():
if isinstance(specification, dict) and specification.get("workspace"):
specification = workspace_dependencies.get(alias, specification)
package = specification.get("package", alias) if isinstance(specification, dict) else alias
yield package.replace("_", "-")
elif isinstance(value, dict):
yield from dependency_packages(value, workspace_dependencies)
def sync_classification_errors() -> list[str]:
"""Require a deliberate sync policy when a persisted model gains a field."""
core = ROOT / "crates" / "nyaterm-core" / "src"
catalog = json.loads((core / "sync_schema" / "fields.json").read_text(encoding="utf-8"))
errors: list[str] = []
for model, entry in catalog.items():
source = (core / entry["source"]).read_text(encoding="utf-8")
declaration = re.search(r"(?:pub(?:\([^)]*\))? )?(?:struct|enum) " + re.escape(model) + r"\s*\{", source)
if declaration is None:
errors.append(f"sync_schema: classification refers to missing model {model}")
continue
start = end = declaration.end()
depth = 1
while depth and end < len(source):
depth += (source[end] == "{") - (source[end] == "}")
end += 1
fields = set(re.findall(r"^\s+(?:pub )?(\w+)\s*:", source[start:end - 1], re.MULTILINE))
policies = entry["fields"]
for field in fields - policies.keys():
errors.append(f"sync_schema: {model}.{field} needs explicit field classification")
for field in policies.keys() - fields:
errors.append(f"sync_schema: stale classification for {model}.{field}")
for field, policy in policies.items():
if policy not in {"identity", "local", "secret", "split", "unsupported"} and not re.fullmatch(r"(?:portable|conditional):[a-z_]+", policy):
errors.append(f"sync_schema: invalid policy for {model}.{field}")
return errors
def main() -> int:
try:
allowlist = load_allowlist()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"architecture check configuration error: {error}", file=sys.stderr)
return 2
errors: list[str] = []
path_declarations = matching_counts(re.compile(r"#\s*\[\s*path\s*="), RUST_FILES)
for path, count in path_declarations.items():
errors.append(f"module_tree: #[path] is forbidden in {path} ({count})")
wildcard_imports = matching_counts(re.compile(r"\buse\s+super::\*\s*;"), RUST_FILES)
errors.extend(check_budget("wildcard_super_import", wildcard_imports, allowlist))
feature_files = tuple(
path
for path in RUST_FILES
if relative(path).startswith("crates/nyaterm-desktop/src/features/")
)
thread_spawns = matching_counts(
re.compile(r"\b(?:std::thread|thread)::spawn\s*\("), feature_files
)
errors.extend(check_budget("desktop_feature_thread_spawn", thread_spawns, allowlist))
desktop_files = tuple(
path
for path in RUST_FILES
if relative(path).startswith("crates/nyaterm-desktop/src/")
)
thread_builders = matching_counts(
re.compile(r"\b(?:std::)?thread::Builder::new\s*\(\s*\)"), desktop_files
)
errors.extend(check_budget("desktop_thread_builder", thread_builders, allowlist))
raw_scrolls = matching_counts(
re.compile(r"\.overflow_(?:x_|y_)?scroll\s*\(\s*\)"), RUST_FILES
)
errors.extend(check_budget("raw_scroll_container", raw_scrolls, allowlist))
errors.extend(dependency_errors())
errors.extend(sync_classification_errors())
if errors:
print("Architecture check failed:", file=sys.stderr)
for error in sorted(errors):
print(f"- {error}", file=sys.stderr)
return 1
print("Architecture check passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())