diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml new file mode 100644 index 0000000..acaf4bc --- /dev/null +++ b/.github/workflows/architecture.yml @@ -0,0 +1,72 @@ +name: Architecture contracts + +on: + pull_request: + push: + branches: [main] + merge_group: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: architecture-${{ github.ref }} + cancel-in-progress: true + +jobs: + architecture-contracts: + name: architecture-contracts + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + PYTHONUTF8: "1" + PYTHONDONTWRITEBYTECODE: "1" + CARGO_TARGET_DIR: ${{ runner.temp }}/pebrel-i18n-contract + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Test the guardrails themselves + run: >- + python3 -m unittest + scripts.tests.test_architecture_budgets + scripts.tests.test_architecture_dependencies + scripts.tests.test_architecture_governance + - name: Check source size and dependency direction + env: + ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} + run: | + arguments=() + if [[ -n "$ARCHITECTURE_BASE" && ! "$ARCHITECTURE_BASE" =~ ^0+$ ]]; then + arguments+=(--base "$ARCHITECTURE_BASE") + fi + python3 scripts/check_architecture.py "${arguments[@]}" + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + - name: Test shared settings without the UI dependency graph + run: | + mkdir -p "$RUNNER_TEMP/pebrel-contracts" + rustc --test --edition 2024 --crate-name nebula_settings \ + nebula_settings/src/lib.rs -o "$RUNNER_TEMP/pebrel-contracts/settings" + "$RUNNER_TEMP/pebrel-contracts/settings" + - name: Test the Rust line-budget adapter + env: + CARGO_MANIFEST_DIR: ${{ github.workspace }}/nebula_app + run: | + rustc --test --edition 2024 nebula_app/tests/file_line_budget.rs \ + -o "$RUNNER_TEMP/pebrel-contracts/line-budget" + "$RUNNER_TEMP/pebrel-contracts/line-budget" + - name: Test renderer-independent translation and allocation contracts + run: cargo test --manifest-path tools/i18n-contract/Cargo.toml --locked + - name: Test branding and image compatibility + run: >- + python3 -m unittest + scripts.tests.test_pebrel_branding + scripts.tests.test_windows_resources + scripts.tests.test_app_icons diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d92cda..5b24f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Every release entry is provided in English and Simplified Chinese. - SSH editing now offers common usernames alongside recent entries, keeps manual usernames available, and makes host icons and pin controls clearer. Connection screens show completed stages and failures more accurately. - Terminal completion lists now scroll to reveal keyboard selections, support wheel scrolling and hover feedback, and clear stale list positions when the query changes. - Completion, bell, and Agent attention notifications now follow their source pane, including after a tab moves to another window. Clicking a notification focuses that pane, and activity in one pane no longer suppresses another pane's notification. +- Contributors can run the shared source-size, dependency-direction, translation, and branding checks locally and in the architecture workflow. Cross-platform metadata checks are documented separately from native build and UI validation. ### 简体中文 @@ -85,6 +86,7 @@ Every release entry is provided in English and Simplified Chinese. - SSH 编辑器在最近使用项之外提供常用用户名,保留手动输入,并改进主机图标与置顶控件;连接界面更准确地显示已完成阶段和失败状态。 - 终端补全列表现在会滚动到键盘选中项,支持滚轮浏览与悬停反馈,并在查询变化后清除过期列表位置。 - 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 +- 贡献者可在本地及架构工作流中运行统一的源码规模、依赖方向、翻译和品牌检查;跨平台元数据检查与原生构建、界面验证的边界分别说明。 ### Contributors - [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。 diff --git a/architecture/dependencies.toml b/architecture/dependencies.toml new file mode 100644 index 0000000..4615804 --- /dev/null +++ b/architecture/dependencies.toml @@ -0,0 +1,37 @@ +version = 1 +renderer_packages = ["gpui", "gpui_platform", "gpui-component", "gpui-component-assets", "winit", "glutin", "crossfont"] + +[crates.nebula_app] +layer = "application" +dependencies = ["nebula_terminal", "nebula_config", "nebula_config_derive", "nebula_settings", "nebula_split", "nebula-completions"] +build-dependencies = ["nebula_settings"] +dev-dependencies = [] + +[crates.nebula_terminal] +layer = "core" + +[crates.nebula_config] +layer = "core" +dev-dependencies = ["nebula_config_derive"] + +[crates.nebula_config_derive] +layer = "core" +dev-dependencies = ["nebula_config"] + +[crates.nebula-completions] +layer = "core" + +[crates.nebula_settings] +layer = "core" +zero_production_dependencies = true + +[crates.nebula_split] +layer = "core" +zero_production_dependencies = true + +[crates.nebula_hook] +layer = "hook" +zero_production_dependencies = true + +[crates.nebula_gpui] +layer = "lab" diff --git a/architecture/file-budgets.txt b/architecture/file-budgets.txt new file mode 100644 index 0000000..9df7572 --- /dev/null +++ b/architecture/file-budgets.txt @@ -0,0 +1,23 @@ +limit 2000 +root nebula_app +root nebula_terminal +root nebula_config +root nebula_config_derive +root nebula-completions +root nebula_hook +root nebula_gpui +root nebula_settings +root nebula_split +root scripts +root tools +nebula_app/src/display/chrome.rs 2442 +nebula_app/src/display/command_palette.rs 3935 +nebula_app/src/display/markdown_view.rs 2067 +nebula_app/src/display/mod.rs 11150 +nebula_app/src/display/settings.rs 8434 +nebula_app/src/display/terminal_math.rs 2783 +nebula_app/src/event.rs 3594 +nebula_app/src/gpui_shell/terminal/view.rs 2416 +nebula_app/src/gpui_shell/workspace.rs 4798 +nebula_app/src/window_context.rs 3735 +nebula_terminal/src/term/mod.rs 2716 diff --git a/docs/release-notes/unreleased.md b/docs/release-notes/unreleased.md index 41c7362..2762293 100644 --- a/docs/release-notes/unreleased.md +++ b/docs/release-notes/unreleased.md @@ -41,6 +41,7 @@ This file contains changes that are not part of a numbered release yet. - SSH editing now offers common usernames alongside recent entries, keeps manual usernames available, and makes host icons and pin controls clearer. Connection screens show completed stages and failures more accurately. - Terminal completion lists now scroll to reveal keyboard selections, support wheel scrolling and hover feedback, and clear stale list positions when the query changes. - Completion, bell, and Agent attention notifications now follow their source pane, including after a tab moves to another window. Clicking a notification focuses that pane, and activity in one pane no longer suppresses another pane's notification. +- Contributors can run the shared source-size, dependency-direction, translation, and branding checks locally and in the architecture workflow. Cross-platform metadata checks are documented separately from native build and UI validation. ## 中文 @@ -81,6 +82,7 @@ This file contains changes that are not part of a numbered release yet. - SSH 编辑器在最近使用项之外提供常用用户名,保留手动输入,并改进主机图标与置顶控件;连接界面更准确地显示已完成阶段和失败状态。 - 终端补全列表现在会滚动到键盘选中项,支持滚轮浏览与悬停反馈,并在查询变化后清除过期列表位置。 - 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 +- 贡献者可在本地及架构工作流中运行统一的源码规模、依赖方向、翻译和品牌检查;跨平台元数据检查与原生构建、界面验证的边界分别说明。 ## Contributors - [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。 diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index 012517a..7f34b3b 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -6,6 +6,24 @@ use super::*; mod tests { use super::*; + fn remove_test_directory(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match std::fs::remove_dir_all(path) { + Ok(()) => return, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) + if cfg!(windows) + && matches!(error.raw_os_error(), Some(32 | 33)) + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(20)); + }, + Err(error) => panic!("could not remove test directory {}: {error}", path.display()), + } + } + } + #[test] fn toggle_switches_views_without_closing() { let mut p = SidePanel::new(); @@ -104,7 +122,7 @@ mod tests { panel.wait_snapshot(); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -129,7 +147,7 @@ mod tests { panel.wait_snapshot(); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -398,7 +416,7 @@ mod tests { assert_eq!(rows[2].depth, 1); p.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -455,7 +473,7 @@ mod tests { assert!(panel.custom_root_active(), "upward navigation is window-local"); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] @@ -485,7 +503,7 @@ mod tests { assert!(!panel.click_drag_source(&stale), "a changed source row must be ignored"); panel.file_index.release_for_test(); - std::fs::remove_dir_all(&base).unwrap(); + remove_test_directory(&base); } #[test] diff --git a/nebula_app/tests/file_line_budget.rs b/nebula_app/tests/file_line_budget.rs index b63fac6..ad65848 100644 --- a/nebula_app/tests/file_line_budget.rs +++ b/nebula_app/tests/file_line_budget.rs @@ -1,115 +1,103 @@ -//! 文件行数预算守护(docs/project-constraints.md 第 1 条)。 -//! -//! 单个 `.rs` 文件硬上限 2000 行;存量超标文件在 [`GRANDFATHERED`] 登记, -//! 登记值只允许下调。测试失败的正确动作是拆文件,不是上调预算。 - +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -/// 新代码红线:超过它就必须拆(docs/project-constraints.md)。 -const MAX_LINES: usize = 2000; +const POLICY: &str = include_str!("../../architecture/file-budgets.txt"); -/// 存量豁免清单(相对仓库根,正斜杠路径 → 行数上限)。 -/// -/// 登记值 = 收录当天的行数加不足百行的呼吸空间:小修小补不触线,成规模 -/// 新增必须伴随拆分。拆小后同步下调或删除对应条目。 -const GRANDFATHERED: &[(&str, usize)] = &[ - ("nebula_app/src/display/mod.rs", 12000), - ("nebula_app/src/display/settings.rs", 8650), - // 2026-08-31 下调(5150 → 4980):VCS 抽屉页搬到 workspace/vcs_panel.rs。 - ("nebula_app/src/gpui_shell/workspace.rs", 4880), - ("nebula_app/src/display/command_palette.rs", 3980), - ("nebula_app/src/window_context.rs", 3750), - ("nebula_app/src/event.rs", 3620), - ("nebula_terminal/src/term/mod.rs", 2760), - // 2026-08-17 上调(2900 → 2970):draw_overlays 几何抽成两壳共享的 plan。 - ("nebula_app/src/display/terminal_math.rs", 2800), - // 2026-08-31 条目删除:side_panel.rs(4031 行)拆成 side_panel/{mod,vcs, - // enumerate,render,tests}.rs,最大的 render.rs 也只有 869 行,全部在红线内。 - ("nebula_app/src/gpui_shell/terminal/view.rs", 2460), - ("nebula_app/src/display/chrome.rs", 2550), - ("nebula_app/src/display/markdown_view.rs", 2150), -]; - -/// 这些目录不受预算约束:构建产物、vendored 第三方代码、版本库内部。 -fn excluded(component: &str) -> bool { - component == ".git" - || component == "third_party" - || component == "target" - || component.starts_with(".target") +fn line_count(content: &[u8]) -> usize { + content.iter().filter(|byte| **byte == b'\n').count() + + usize::from(!content.is_empty() && content.last() != Some(&b'\n')) } -fn collect_rust_files(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); +fn collect_sources(root: &Path, files: &mut Vec) { + assert!(!root.is_symlink(), "source directory must not be a symlink: {}", root.display()); + let cargo_root = root.join("Cargo.toml").is_file(); + for entry in fs::read_dir(root).expect("source directories must be readable") { + let entry = entry.expect("source directory entries must be readable"); let name = entry.file_name(); let name = name.to_string_lossy(); - // 隐藏项(.git、.tmp_* 草稿等)不是源码,不受预算约束。 - if name.starts_with('.') { + if name == "node_modules" || name == "__pycache__" || (cargo_root && name == "target") { continue; } + let path = entry.path(); + assert!(!path.is_symlink(), "source must not be a symlink: {}", path.display()); if path.is_dir() { - if !excluded(&name) { - collect_rust_files(&path, out); - } - } else if path.extension().is_some_and(|extension| extension == "rs") { - out.push(path); + collect_sources(&path, files); + } else if path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| matches!(value, "rs" | "py" | "ps1" | "mjs" | "js" | "sh" | "lua")) + { + files.push(path); } } } #[test] fn every_source_file_respects_its_line_budget() { - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("nebula_app sits directly under the repo root") - .to_path_buf(); - - let mut files = Vec::new(); - collect_rust_files(&repo_root, &mut files); - assert!( - files.len() > 100, - "行数守护扫到的文件数异常({}),排除规则可能吞掉了源码目录", - files.len() - ); - - let mut violations = Vec::new(); - let mut stale_grants = Vec::new(); - for path in files { - let relative = path - .strip_prefix(&repo_root) - .expect("collected files live under the repo root") - .to_string_lossy() - .replace('\\', "/"); - let Ok(content) = fs::read(&path) else { - panic!("无法读取 {relative}"); - }; - // 按换行字节计数:与编码无关,空文件为 0 行。 - let lines = content.iter().filter(|byte| **byte == b'\n').count() - + usize::from(!content.is_empty() && content.last() != Some(&b'\n')); - let grant = - GRANDFATHERED.iter().find(|(name, _)| *name == relative).map(|(_, budget)| *budget); - let budget = grant.unwrap_or(MAX_LINES); - if lines > budget { - violations.push(format!("{relative}: {lines} 行 > 预算 {budget}")); - } - // 豁免文件瘦身到红线以内后,条目必须删除,防止预算复胖。 - if let Some(budget) = grant - && lines <= MAX_LINES - { - stale_grants.push(format!( - "{relative}: 已降到 {lines} 行(≤{MAX_LINES}),豁免条目({budget})应删除" - )); + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let mut limit = None; + let mut roots = HashSet::new(); + let mut exceptions = HashMap::new(); + for record in POLICY.lines().filter(|record| !record.trim().is_empty()) { + let fields = record.split_whitespace().collect::>(); + assert_eq!(fields.len(), 2, "invalid budget record: {record}"); + match fields[0] { + "limit" => { + assert!(limit.is_none(), "duplicate default limit"); + limit = Some(fields[1].parse::().unwrap()); + }, + "root" => assert!(roots.insert(fields[1]), "duplicate source root"), + name => { + let count = fields[1].parse::().unwrap(); + assert!(exceptions.insert(name, count).is_none(), "duplicate exception: {name}"); + }, } } - - assert!( - violations.is_empty(), - "以下文件超出行数预算(docs/project-constraints.md 第 1 条,正确动作是拆文件):\n{}", - violations.join("\n") - ); - assert!(stale_grants.is_empty(), "以下豁免条目已过期:\n{}", stale_grants.join("\n")); + let limit = limit.expect("missing default limit"); + assert!(limit > 0 && !roots.is_empty()); + let mut files = Vec::new(); + for root in roots { + assert!(!Path::new(root).is_absolute() && !root.contains("..") && !root.contains(':')); + let mut directory = repo_root.to_path_buf(); + for component in Path::new(root).components() { + directory.push(component); + assert!(!directory.is_symlink(), "source ancestor must not be a symlink"); + } + collect_sources(&repo_root.join(root), &mut files); + } + assert!(files.len() > 100, "unexpectedly empty source scan: {}", files.len()); + files.sort(); + let mut errors = Vec::new(); + for path in files { + let name = path.strip_prefix(repo_root).unwrap().to_string_lossy().replace('\\', "/"); + let content = fs::read(&path).expect("source files must be readable"); + let count = line_count(&content); + let exception = exceptions.remove(name.as_str()); + let budget = exception.unwrap_or(limit); + if count > budget { + errors.push(format!("{name}: {count} lines > {budget}; split by responsibility")); + } + if exception.is_some() && count <= limit { + errors.push(format!("{name}: remove the obsolete exception ({count} lines)")); + } + } + for name in exceptions.keys() { + errors.push(format!("{name}: remove the missing-source exception")); + } + assert!(errors.is_empty(), "docs/project-constraints.md:\n{}", errors.join("\n")); +} + +#[test] +fn physical_lines_ignore_encoding_and_newline_convention() { + for (content, expected) in [ + ("", 0), + ("\n", 1), + ("a\nb", 2), + ("a\r\nb\r\n", 2), + ("中文\n", 1), + ("vertical\u{000b}tab", 1), + ] { + assert_eq!(line_count(content.as_bytes()), expected); + } } diff --git a/scripts/architecture/__init__.py b/scripts/architecture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/architecture/budgets.py b/scripts/architecture/budgets.py new file mode 100644 index 0000000..a9ef1d2 --- /dev/null +++ b/scripts/architecture/budgets.py @@ -0,0 +1,139 @@ +from dataclasses import dataclass +import os +from pathlib import Path, PurePosixPath + + +BUDGET_PATH = "architecture/file-budgets.txt" +SOURCE_SUFFIXES = {".rs", ".py", ".ps1", ".mjs", ".js", ".sh", ".lua"} +EXCLUDED = {"node_modules", "__pycache__"} + + +def line_count(content: bytes) -> int: + return content.count(b"\n") + int(bool(content) and not content.endswith(b"\n")) + + +def relative_path(value: str) -> str: + path = PurePosixPath(value) + if (not value or path.is_absolute() or ".." in path.parts + or "\\" in value or ":" in value or path.as_posix() != value + or any(part.startswith(".") for part in path.parts)): + raise ValueError(f"invalid first-party path: {value!r}") + return value + + +def checked_path(root: Path, relative: str) -> Path: + path = PurePosixPath(relative) + if path.is_absolute() or "\\" in relative or ":" in relative: + raise ValueError(f"invalid repository path: {relative}") + boundary = root.resolve() + current = boundary + for component in path.parts: + current = current.parent if component == ".." else current / component + if not current.is_relative_to(boundary) or current.is_symlink(): + raise ValueError(f"out-of-repository or symlinked source path: {relative}") + return current + + +@dataclass +class Budget: + limit: int + roots: tuple[str, ...] + exceptions: dict[str, int] + + @classmethod + def parse(cls, text: str) -> "Budget": + limit = None + roots = [] + exceptions = {} + for number, record in enumerate(text.splitlines(), 1): + if not record.strip(): + continue + fields = record.split() + if len(fields) != 2: + raise ValueError(f"{BUDGET_PATH}:{number}: expected two fields") + name, value = fields + if name == "limit": + if limit is not None or not value.isascii() or not value.isdecimal() or int(value) < 1: + raise ValueError("invalid or duplicate line limit") + limit = int(value) + elif name == "root": + relative_path(value) + if value in roots: + raise ValueError(f"duplicate source root: {value}") + roots.append(value) + else: + relative_path(name) + if name in exceptions or not value.isascii() or not value.isdecimal(): + raise ValueError(f"invalid or duplicate exception: {name}") + exceptions[name] = int(value) + if limit is None or not roots: + raise ValueError("line budget needs a limit and source roots") + for name, count in exceptions.items(): + if count <= limit or PurePosixPath(name).suffix not in SOURCE_SUFFIXES: + raise ValueError(f"invalid exception: {name} ({count})") + if not any(name.startswith(root + "/") for root in roots): + raise ValueError(f"exception outside source roots: {name}") + for root in roots: + if any(root.startswith(other + "/") for other in roots if other != root): + raise ValueError(f"overlapping source root: {root}") + return cls(limit, tuple(roots), exceptions) + + +def source_files(root: Path, roots: tuple[str, ...]): + def fail(error): + raise error + + for relative in roots: + directory = checked_path(root, relative) + if directory.is_symlink() or not directory.is_dir(): + raise ValueError(f"missing or symlinked source root: {relative}") + for parent, directories, files in os.walk(directory, onerror=fail): + excluded = EXCLUDED | ({"target"} if "Cargo.toml" in files else set()) + directories[:] = sorted( + name for name in directories + if name not in excluded + ) + for name in directories + files: + if (Path(parent) / name).is_symlink(): + raise ValueError(f"symlink in first-party sources: {parent}/{name}") + for name in sorted(files): + path = Path(parent) / name + if path.suffix in SOURCE_SUFFIXES: + yield path + + +def check(root: Path, budget: Budget, previous=None, read_previous=None): + errors = [] + notices = [] + counts = { + path.relative_to(root.resolve()).as_posix(): line_count(path.read_bytes()) + for path in source_files(root, budget.roots) + } + if not counts: + errors.append("no first-party sources found") + if previous is not None: + if budget.limit > previous.limit: + errors.append("line limit may not increase against the base revision") + if not set(previous.roots).issubset(budget.roots): + errors.append("source roots may not be removed from the budget") + for name, limit in budget.exceptions.items(): + if name not in previous.exceptions or limit > previous.exceptions[name]: + errors.append(f"{name}: new or increased exception against base revision") + for name, count in counts.items(): + limit = budget.exceptions.get(name, budget.limit) + if previous is not None and name in previous.exceptions and read_previous: + content = read_previous(name) + if content is None: + errors.append(f"{name}: exception has no source in base revision") + else: + limit = min(limit, max(budget.limit, line_count(content))) + if count > limit: + errors.append(f"{name}: {count} lines > {limit}; split by responsibility") + elif name in budget.exceptions: + if count <= budget.limit: + errors.append(f"{name}: remove the exception; now {count} lines") + elif count < budget.exceptions[name]: + notices.append(f"{name}: tighten exception to {count}") + for name in sorted(budget.exceptions.keys() - counts.keys()): + errors.append(f"{name}: remove missing-source exception") + return errors, notices, counts diff --git a/scripts/architecture/dependencies.py b/scripts/architecture/dependencies.py new file mode 100644 index 0000000..38e9466 --- /dev/null +++ b/scripts/architecture/dependencies.py @@ -0,0 +1,134 @@ +from pathlib import Path +import tomllib + +from scripts.architecture.budgets import checked_path, relative_path + + +POLICY_PATH = "architecture/dependencies.toml" +KINDS = ("dependencies", "build-dependencies", "dev-dependencies") + + +def load(path: Path) -> dict: + return tomllib.loads(path.read_text(encoding="utf-8-sig")) + + +def dependency_entries(manifest: dict, workspace: dict): + for section in [manifest, *manifest.get("target", {}).values()]: + for kind in KINDS: + for alias, original in section.get(kind, {}).items(): + specification = {"version": original} if isinstance(original, str) else original + inherited = specification.get("workspace", False) + if inherited: + if alias not in workspace.get("dependencies", {}): + raise ValueError(f"unresolved workspace dependency: {alias}") + base = workspace["dependencies"][alias] + base = {"version": base} if isinstance(base, str) else base + specification = dict(base, **specification) + yield kind, alias, specification, inherited + + +def cycles(graph: dict[str, set[str]]) -> list[str]: + visited = set() + active = [] + errors = [] + + def visit(node): + if node in active: + start = active.index(node) + errors.append("production dependency cycle: " + " -> ".join(active[start:] + [node])) + return + if node in visited: + return + active.append(node) + for target in sorted(graph[node]): + visit(target) + active.pop() + visited.add(node) + + for node in sorted(graph): + visit(node) + return errors + + +def check(root: Path, policy: dict, source_roots=None, scanned_paths=None): + workspace = load(root / "Cargo.toml")["workspace"] + members = workspace["members"] + if not isinstance(members, list) or not members or len(set(members)) != len(members): + raise ValueError("workspace architecture needs nonempty, unique members") + if any(any(character in member for character in "*?[") for member in members): + raise ValueError("workspace members must be explicit for architecture classification") + for member in members: + relative_path(member) + checked_path(root, member) + if workspace.get("exclude"): + raise ValueError("workspace exclusions require explicit architecture support") + rules = policy.get("crates", {}) + errors = [] + if policy.get("version") != 1: + raise ValueError("unsupported dependency policy version") + if policy.keys() - {"version", "crates", "renderer_packages"}: + raise ValueError("unknown dependency policy field") + for member in sorted(set(members) ^ rules.keys()): + errors.append(f"{member}: workspace membership and architecture classification differ") + manifests = {member: load(root / member / "Cargo.toml") for member in members} + names = {manifest["package"]["name"]: member for member, manifest in manifests.items()} + paths = {(root / member).resolve(): member for member in members} + if len(names) != len(members): + raise ValueError("duplicate workspace package names") + graph = {member: set() for member in members} + source_roots = source_roots if source_roots is not None else members + forbidden = set(policy.get("renderer_packages", [])) + for member, manifest in manifests.items(): + rule = rules.get(member) + if rule is None: + continue + if rule.keys() - {*KINDS, "layer", "zero_production_dependencies"}: + errors.append(f"{member}: unknown dependency rule field") + if rule.get("layer") not in {"core", "application", "lab", "hook"}: + errors.append(f"{member}: invalid architecture layer") + continue + targets = [manifest.get("lib", {})] + targets.extend(target for kind in ("bin", "test", "bench", "example") for target in manifest.get(kind, [])) + build = manifest["package"].get("build") + if isinstance(build, str): + targets.append({"path": build}) + for target_spec in targets: + if "path" in target_spec: + target_path = checked_path(root, member + "/" + target_spec["path"]) + covered = any(target_path.is_relative_to(checked_path(root, source)) for source in source_roots) + if scanned_paths is not None: + covered = target_path.relative_to(root.resolve()).as_posix() in scanned_paths + if not target_path.is_file() or not covered or target_path.suffix != ".rs": + errors.append(f"{member}: invalid custom source path {target_spec['path']}") + for kind in KINDS: + for target in rule.get(kind, []): + if target not in manifests or target == member: + errors.append(f"{member}: invalid {kind} allowance: {target}") + for kind, alias, specification, inherited in dependency_entries(manifest, workspace): + name = specification.get("package", alias) + target = None + if "path" in specification: + directory = root if inherited else root / member + resolved = (directory / specification["path"]).resolve() + local_target = paths.get(resolved) + if local_target is None: + errors.append(f"{member}: unclassified path dependency {alias}") + continue + if names.get(name) != local_target: + errors.append(f"{member}: dependency package/path mismatch for {alias}") + continue + target = local_target + if rule.get("zero_production_dependencies", False) and kind != "dev-dependencies": + errors.append(f"{member}: zero-dependency contract forbids {kind} {alias}") + if rule["layer"] in {"core", "hook"} and kind != "dev-dependencies" and name in forbidden: + errors.append(f"{member}: renderer dependency {name} is not allowed in core") + if target is not None: + if target not in rule.get(kind, []): + errors.append(f"{member}: forbidden {kind} edge to {target}") + if kind != "dev-dependencies": + target_layer = rules.get(target, {}).get("layer") + if ((rule["layer"] == "core" and target_layer != "core") + or (rule["layer"] == "application" and target_layer == "lab")): + errors.append(f"{member}: forbidden production layer direction to {target}") + graph[member].add(target) + return errors + cycles(graph), members diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py new file mode 100644 index 0000000..0375e35 --- /dev/null +++ b/scripts/check_architecture.py @@ -0,0 +1,78 @@ +import argparse +from pathlib import Path +import subprocess +import sys + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scripts.architecture import budgets, dependencies + + +ROOT = Path(__file__).resolve().parents[1] + + +class Revision: + def __init__(self, root: Path, reference: str): + self.root = root + result = subprocess.run( + ["git", "rev-parse", "--verify", "--end-of-options", reference + "^{commit}"], + cwd=root, capture_output=True, text=True, encoding="utf-8", check=True, + ) + self.commit = result.stdout.strip() + + def read(self, path: str): + result = subprocess.run( + ["git", "--literal-pathspecs", "ls-tree", "--name-only", "-z", self.commit, "--", path], + cwd=self.root, capture_output=True, check=True, + ) + if not result.stdout: + return None + return subprocess.run( + ["git", "show", f"{self.commit}:{path}"], + cwd=self.root, capture_output=True, check=True, + ).stdout + + +def run(root: Path, base: str | None = None, report: bool = False) -> int: + budget = budgets.Budget.parse((root / budgets.BUDGET_PATH).read_text(encoding="utf-8")) + revision = Revision(root, base) if base else None + previous_source = revision.read(budgets.BUDGET_PATH) if revision else None + previous = budgets.Budget.parse(previous_source.decode("utf-8")) if previous_source is not None else None + errors, notices, counts = budgets.check(root, budget, previous, revision.read if revision else None) + dependency_errors, members = dependencies.check( + root, dependencies.load(root / dependencies.POLICY_PATH), budget.roots, set(counts) + ) + errors.extend(dependency_errors) + for member in members: + if member not in budget.roots: + errors.append(f"{member}: workspace member missing from line-budget roots") + print(f"Architecture: {len(counts)} source files, {len(members)} crates, {len(budget.exceptions)} legacy exceptions") + advisory = sorted(((name, count) for name, count in counts.items() if count > 800), key=lambda item: -item[1]) + print(f"Review guidance only: {len(advisory)} files exceed the 800-line soft target") + if revision and previous is None: + print("Initial policy adoption: no budget in base; review the initial debt inventory") + for notice in notices: + print(f"NOTICE: {notice}") + if report: + for name, count in advisory: + print(f"REVIEW: {name}: {count} lines") + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return int(bool(errors)) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Offline first-party size and dependency contracts (Python 3.11+)") + parser.add_argument("--base", help="PR base commit; rejects new/increased debt and regrowth") + parser.add_argument("--report", action="store_true", help="also list advisory-size files") + options = parser.parse_args() + try: + return run(ROOT, options.base, options.report) + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f"Architecture check could not complete: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py new file mode 100644 index 0000000..bbfa0f6 --- /dev/null +++ b/scripts/tests/test_architecture_budgets.py @@ -0,0 +1,165 @@ +from pathlib import Path +import io +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from scripts.architecture.budgets import Budget, check, line_count, source_files +from scripts import check_architecture + + +class LineBudgetTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + (self.root / "app/src").mkdir(parents=True) + + def write(self, name, count): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"source\n" * count) + + def policy(self, exceptions="", limit=2000): + return Budget.parse(f"limit {limit}\nroot app\n{exceptions}") + + def test_physical_lines(self): + for content, expected in [(b"", 0), (b"\n", 1), (b"a\nb", 2), + (b"a\r\nb\r\n", 2), ("中文\n".encode(), 1), + (b"vertical\vtab", 1)]: + with self.subTest(content=content): + self.assertEqual(line_count(content), expected) + + def test_soft_target_is_not_a_failure(self): + for count in (0, 1, 800, 801, 2000): + with self.subTest(count=count): + self.write("app/src/view.rs", count) + self.assertEqual(check(self.root, self.policy())[0], []) + + def test_hard_limit_includes_scripts_and_untracked_files(self): + for suffix in ("rs", "py", "ps1", "mjs", "js", "sh", "lua"): + with self.subTest(suffix=suffix): + self.write(f"app/src/new.{suffix}", 2001) + self.assertTrue(check(self.root, self.policy())[0]) + + def test_exact_exception_and_tightening_notice(self): + self.write("app/src/legacy.rs", 2100) + errors, notices, _ = check(self.root, self.policy("app/src/legacy.rs 2101")) + self.assertEqual(errors, []) + self.assertIn("tighten exception to 2100", notices[0]) + self.assertEqual(check(self.root, self.policy("app/src/legacy.rs 2100"))[0], []) + + def test_deleted_and_shrunk_exceptions_fail(self): + budget = self.policy("app/src/legacy.rs 2100") + self.write("app/src/other.rs", 1) + self.assertIn("missing-source", check(self.root, budget)[0][0]) + self.write("app/src/legacy.rs", 2000) + self.assertIn("remove the exception", check(self.root, budget)[0][0]) + + def test_duplicate_and_invalid_records_fail(self): + for text in ( + "limit 2000\nlimit 2000\nroot app", "root app", "limit 0\nroot app", + "limit 2000\nroot app\nroot app", "limit 2000\nroot ../app", "limit ٢٠٠٠\nroot app", + "limit 2000\nroot /app", "limit 2000\nroot D:/app", + "limit 2000\nroot app\nroot app/src", "limit 2000\nroot app\nbogus 2001", + "limit 2000\nroot app\napp/src/file.rs 2000", + "limit 2000\nroot app\nother/file.rs 2100", + "limit 2000\nroot app\napp/src/file.rs 2100\napp/src/file.rs 2100", + ): + with self.subTest(text=text): + with self.assertRaises(ValueError): + Budget.parse(text) + + def test_scanner_includes_hidden_source_but_not_build_outputs(self): + (self.root / "app/Cargo.toml").write_text("[package]\n", encoding="utf-8") + for name in ("app/src/normal.rs", "app/src/target/real.rs", "app/.internal/source.rs", + "app/target/generated.rs", "tmp/probe.rs", "third_party/copied.rs"): + self.write(name, 1) + names = {path.relative_to(self.root).as_posix() for path in source_files(self.root, ("app",))} + self.assertEqual(names, {"app/src/normal.rs", "app/src/target/real.rs", "app/.internal/source.rs"}) + + def test_missing_empty_and_unreadable_roots_fail(self): + with self.assertRaises(ValueError): + list(source_files(self.root, ("missing",))) + self.assertIn("no first-party sources", check(self.root, self.policy())[0][0]) + with patch("scripts.architecture.budgets.os.walk", side_effect=PermissionError("unreadable")): + with self.assertRaises(PermissionError): + list(source_files(self.root, ("app",))) + + def test_symlinks_are_not_followed(self): + self.write("outside.rs", 1) + try: + (self.root / "app/src/link.rs").symlink_to(self.root / "outside.rs") + except OSError as error: + self.skipTest(f"symlink unavailable: {error}") + with self.assertRaises(ValueError): + list(source_files(self.root, ("app",))) + + def test_symlinked_root_ancestor_is_rejected(self): + try: + (self.root / "redirect").symlink_to(self.root / "app", target_is_directory=True) + except OSError as error: + self.skipTest(f"symlink unavailable: {error}") + with self.assertRaises(ValueError): + list(source_files(self.root, ("redirect/src",))) + + def test_empty_existing_base_budget_is_not_bootstrap(self): + with patch("scripts.check_architecture.Revision") as revision: + revision.return_value.read.return_value = b"" + with self.assertRaises(ValueError): + check_architecture.run(check_architecture.ROOT, base="HEAD") + + def test_base_rejects_limit_increase_and_scope_removal(self): + self.write("app/src/view.rs", 1) + self.assertIn("may not increase", check(self.root, self.policy(limit=2100), self.policy())[0][0]) + previous = Budget.parse("limit 2000\nroot app\nroot other") + self.assertIn("may not be removed", check(self.root, self.policy(), previous)[0][0]) + + def test_base_rejects_new_or_increased_exceptions(self): + self.write("app/src/legacy.rs", 2100) + current = self.policy("app/src/legacy.rs 2100") + self.assertIn("new or increased", check(self.root, current, self.policy())[0][0]) + previous = self.policy("app/src/legacy.rs 2099") + self.assertIn("new or increased", check(self.root, current, previous)[0][0]) + + def test_base_actual_size_prevents_regrowth_under_stale_budget(self): + self.write("app/src/legacy.rs", 2101) + budget = self.policy("app/src/legacy.rs 2200") + errors = check(self.root, budget, budget, lambda name: b"line\n" * 2100)[0] + self.assertIn("2101 lines > 2100", errors[0]) + + def test_base_that_already_shrank_below_limit_cannot_regrow(self): + self.write("app/src/legacy.rs", 2100) + budget = self.policy("app/src/legacy.rs 2200") + errors = check(self.root, budget, budget, lambda name: b"line\n" * 1800)[0] + self.assertIn("2100 lines > 2000", errors[0]) + + def test_missing_base_source_is_an_error(self): + self.write("app/src/legacy.rs", 2100) + budget = self.policy("app/src/legacy.rs 2100") + self.assertIn("no source in base", check(self.root, budget, budget, lambda name: None)[0][0]) + + def test_invalid_base_is_not_treated_as_bootstrap(self): + with patch("scripts.check_architecture.subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")): + with self.assertRaises(subprocess.CalledProcessError): + check_architecture.Revision(self.root, "missing") + + def test_unreadable_base_tree_is_not_treated_as_missing_budget(self): + revision = object.__new__(check_architecture.Revision) + revision.root = self.root + revision.commit = "a" * 40 + with patch("scripts.check_architecture.subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")): + with self.assertRaises(subprocess.CalledProcessError): + revision.read("architecture/file-budgets.txt") + + def test_cli_fails_closed_when_configuration_cannot_be_read(self): + with (patch.object(check_architecture, "ROOT", self.root), + patch("sys.argv", ["check_architecture"]), + patch("sys.stderr", new_callable=io.StringIO) as output): + self.assertEqual(check_architecture.main(), 1) + self.assertIn("could not complete", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_architecture_dependencies.py b/scripts/tests/test_architecture_dependencies.py new file mode 100644 index 0000000..722ab60 --- /dev/null +++ b/scripts/tests/test_architecture_dependencies.py @@ -0,0 +1,166 @@ +from pathlib import Path +import tempfile +import unittest + +from scripts.architecture.dependencies import check + + +class DependencyTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.workspace() + self.manifest("app", "application") + self.manifest("core", "core-model") + self.manifest("derive", "derive-helper") + self.policy = { + "version": 1, + "renderer_packages": ["gpui", "winit"], + "crates": { + "app": {"layer": "application", "dependencies": ["core"]}, + "core": {"layer": "core", "dev-dependencies": ["derive"]}, + "derive": {"layer": "core", "dev-dependencies": ["core"]}, + }, + } + + def workspace(self, extra="", members='"app", "core", "derive"'): + (self.root / "Cargo.toml").write_text(f"[workspace]\nmembers = [{members}]\n{extra}", encoding="utf-8") + + def manifest(self, member, package, extra=""): + directory = self.root / member + directory.mkdir(exist_ok=True) + (directory / "Cargo.toml").write_text( + f'[package]\nname = "{package}"\nversion = "0.0.0"\n{extra}', encoding="utf-8" + ) + + def errors(self): + return check(self.root, self.policy)[0] + + def test_legal_inward_dependency_and_package_rename(self): + self.manifest("app", "application", '[dependencies]\nmodel = { package = "core-model", path = "../core" }') + self.assertEqual(self.errors(), []) + + def test_reverse_dependency_is_rejected(self): + self.manifest("core", "core-model", '[dependencies]\napplication = { path = "../app" }') + self.assertIn("forbidden dependencies edge to app", self.errors()[0]) + + def test_core_cannot_whitelist_a_reverse_production_layer(self): + self.policy["crates"]["core"]["dependencies"] = ["app"] + self.manifest("core", "core-model", '[dependencies]\napplication = { path = "../app" }') + self.assertIn("forbidden production layer direction", self.errors()[0]) + + def test_optional_target_and_renamed_renderer_are_checked(self): + self.manifest("core", "core-model", '''[target.'cfg(windows)'.dependencies] +display_adapter = { package = "gpui", version = "0.2", optional = true }''') + self.assertIn("renderer dependency gpui", self.errors()[0]) + + def test_workspace_inheritance_resolves_paths_from_workspace_root(self): + self.workspace('[workspace.dependencies]\nmodel = { package = "core-model", path = "core" }') + self.manifest("app", "application", '[dependencies]\nmodel.workspace = true') + self.assertEqual(self.errors(), []) + + def test_workspace_inherited_renderer_is_checked(self): + self.workspace('[workspace.dependencies]\nwindow = { package = "winit", version = "0.30" }') + self.manifest("core", "core-model", '[dependencies]\nwindow.workspace = true') + self.assertIn("renderer dependency winit", self.errors()[0]) + + def test_unresolved_workspace_dependency_fails(self): + self.manifest("core", "core-model", '[dependencies]\nmissing.workspace = true') + with self.assertRaises(ValueError): + self.errors() + + def test_build_dependencies_are_checked(self): + self.manifest("core", "core-model", '[build-dependencies]\napplication = { path = "../app" }') + self.assertIn("forbidden build-dependencies", self.errors()[0]) + + def test_production_cycle_is_rejected_even_when_edges_are_allowed(self): + self.policy["crates"]["core"]["build-dependencies"] = ["app"] + self.manifest("core", "core-model", '[build-dependencies]\napplication = { path = "../app" }') + self.manifest("app", "application", '[dependencies]\ncore-model = { path = "../core" }') + self.assertTrue(any("production dependency cycle" in error for error in self.errors())) + + def test_cargo_legal_dev_cycle_is_not_a_production_cycle(self): + self.manifest("core", "core-model", '[dev-dependencies]\nderive-helper = { path = "../derive" }') + self.manifest("derive", "derive-helper", '[dev-dependencies]\ncore-model = { path = "../core" }') + self.assertEqual(self.errors(), []) + + def test_zero_production_dependencies_allow_test_tools(self): + self.policy["crates"]["core"]["zero_production_dependencies"] = True + self.manifest("core", "core-model", '[dev-dependencies]\nproptest = "1"') + self.assertEqual(self.errors(), []) + self.manifest("core", "core-model", '[dependencies]\nserde = "1"') + self.assertIn("zero-dependency contract", self.errors()[0]) + + def test_unclassified_workspace_member_is_rejected(self): + self.manifest("lab", "lab") + self.workspace(members='"app", "core", "derive", "lab"') + self.assertIn("classification differ", self.errors()[0]) + + def test_unclassified_path_dependency_cannot_hide_an_edge(self): + self.manifest("app", "application", '[dependencies]\nmodel = { path = "../unknown" }') + self.assertIn("unclassified path dependency", self.errors()[0]) + + def test_mismatched_package_and_path_is_rejected(self): + self.manifest("app", "application", '[dependencies]\nderive-helper = { path = "../core" }') + self.assertIn("package/path mismatch", self.errors()[0]) + + def test_comment_or_module_alias_is_not_a_dependency(self): + self.manifest("core", "core-model", '# crate::gpui and #[path = "../display.rs"] are not dependencies') + (self.root / "core/source.rs").write_text('const TEXT: &str = "gpui::Window";', encoding="utf-8") + self.assertEqual(self.errors(), []) + + def test_invalid_toml_and_unknown_policy_fields_fail(self): + self.policy["crates"]["core"]["zero_dependenices"] = True + self.assertIn("unknown dependency rule field", self.errors()[0]) + (self.root / "core/Cargo.toml").write_text("[broken", encoding="utf-8") + with self.assertRaises(ValueError): + self.errors() + + def test_unhandled_workspace_layout_fails_instead_of_skipping(self): + for extra, members in [('exclude = ["other"]', '"app", "core", "derive"'), + ("", '"crates/*"'), ("", '"../outside"')]: + with self.subTest(extra=extra, members=members): + self.workspace(extra, members) + with self.assertRaises(ValueError): + self.errors() + + def test_empty_or_duplicate_workspace_cannot_pass(self): + for members in ("", '"app", "app"'): + with self.subTest(members=members): + self.workspace(members=members) + with self.assertRaises(ValueError): + self.errors() + + def test_custom_source_paths_cannot_escape_coverage(self): + self.manifest("core", "core-model", '[lib]\npath = "../outside.rs"') + self.assertIn("invalid custom source path", self.errors()[0]) + + def test_same_package_from_registry_is_not_a_workspace_edge(self): + self.manifest("app", "application", '[dev-dependencies]\nprevious = { package = "core-model", version = "=0.9.0" }') + self.assertEqual(self.errors(), []) + + def test_shared_custom_source_is_allowed_inside_scanned_roots(self): + (self.root / "derive/shared.rs").write_text("", encoding="utf-8") + self.manifest("core", "core-model", '[lib]\npath = "../derive/shared.rs"') + self.assertEqual(self.errors(), []) + + def test_custom_target_inside_excluded_output_is_rejected(self): + (self.root / "core/target").mkdir() + (self.root / "core/target/real.rs").write_text("", encoding="utf-8") + self.manifest("core", "core-model", '[lib]\npath = "target/real.rs"') + errors = check(self.root, self.policy, scanned_paths=set())[0] + self.assertIn("invalid custom source path", errors[0]) + + def test_symlinked_workspace_ancestor_is_rejected(self): + try: + (self.root / "redirect").symlink_to(self.root, target_is_directory=True) + except OSError as error: + self.skipTest(f"symlink unavailable: {error}") + self.workspace(members='"redirect/core"') + with self.assertRaises(ValueError): + self.errors() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_architecture_governance.py b/scripts/tests/test_architecture_governance.py new file mode 100644 index 0000000..f6f3069 --- /dev/null +++ b/scripts/tests/test_architecture_governance.py @@ -0,0 +1,119 @@ +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DOCUMENTS = ( + "CONTRIBUTING.md", "docs/project-constraints.md", "docs/architecture.md", + "docs/architecture-decisions.md", "docs/engineering-evidence.md", + "docs/internationalization.md", +) + + +class GovernanceTests(unittest.TestCase): + def ignored_paths(self, paths): + result = subprocess.run( + ["git", "check-ignore", "--no-index", "--stdin"], cwd=ROOT, + input="\n".join(paths) + "\n", text=True, encoding="utf-8", + capture_output=True, + ) + self.assertIn(result.returncode, (0, 1), result.stderr) + return set(result.stdout.splitlines()) + + def test_contributor_documents_exist_and_are_not_ignored(self): + for name in DOCUMENTS: + with self.subTest(name=name): + self.assertTrue((ROOT / name).is_file()) + self.assertEqual(self.ignored_paths(DOCUMENTS), set(), "public contributor docs must not be ignored") + + def test_private_investigations_remain_ignored(self): + paths = ( + "docs/private-product-comparison.md", "docs/new-investigation/README.md", + "docs/screenshots/vendor-gap-analysis.md", "docs/release-notes/vendor-study.md", + "docs/screenshots/vendor-research.md", "docs/screenshots/vendor-competitor-notes.md", + "docs/screenshots/research/raw-capture.json", + "docs/release-notes/reference-projects/vendor/src/main.rs", + "docs/screenshots/external-probes/inspect.ps1", + "research/vendor/README.md", "reference-projects/vendor/src/main.rs", + "external-probes/inspect-vendor.py", "impeccable/research.md", + ) + self.assertEqual(self.ignored_paths(paths), set(paths)) + + def test_html_prototypes_do_not_escape_through_public_doc_directories(self): + paths = ( + "settings.html", "settings.htm", "docs/design/settings/index.html", + "docs/screenshots/settings.html", "docs/release-notes/demo.htm", + "scripts/design/settings-prototype.html", "tools/design/settings_mockup.html", + "scripts/design/settings-prototype.htm", "tools/design/settings_mockup.htm", + "prototypes/sidebar/assets/preview.png", "scripts/render_shell_icon.html", + ) + self.assertEqual(self.ignored_paths(paths), set(paths)) + + def test_scratch_scripts_and_caches_remain_ignored(self): + paths = ( + "tmp/test-expanded-icons-20260905.ps1", "tmp/check-tool.py", + "tmp/vendor-probe/main.rs", "tmp-check.ps1", "tmp_check.py", + "scratch/inspect-window.ps1", ".tmp-check.py", ".probe-round/probe.ps1", + "scripts/ghost_repro/probe.ps1", "scripts/drawer_repro.ps1", + "scripts/__pycache__/helper.cpython-313.pyc", "tools/diagnostic.pyc", + "scripts/diagnostic.pyo", ".pytest_cache/v/cache/nodeids", + ".mypy_cache/check.json", ".ruff_cache/cache.bin", + ) + self.assertEqual(self.ignored_paths(paths), set(paths)) + + def test_maintained_tests_dependencies_and_public_assets_stay_visible(self): + paths = ( + *DOCUMENTS, ".github/CODEOWNERS", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/workflows/architecture.yml", "architecture/dependencies.toml", + "architecture/file-budgets.txt", "scripts/check_architecture.py", + "scripts/tests/test_architecture_governance.py", "scripts/tests/new_regression.py", + "scripts/conformance/tests/test_harness.py", "scripts/launch_probe_instance.ps1", + "scripts/probe_ssh_connect.ps1", "tools/probe_ime.rs", + "tools/i18n-contract/Cargo.toml", "tools/i18n-contract/Cargo.lock", + "nebula_app/tests/i18n_contract.rs", "nebula_app/tests/fixtures/page.html", + "nebula_app/i18n/fr-FR.json", "third_party/winit-0.30.13/Cargo.toml", + "third_party/winit-0.30.13/src/lib.rs", "docs/screenshots/SHOTLIST.md", + "docs/screenshots/hero.png", "docs/release-notes/v1.5.0.md", + "docs/skills/nebula-runtime/SKILL.md", + ) + self.assertEqual(self.ignored_paths(paths), set()) + + def test_relative_document_links_resolve(self): + for name in DOCUMENTS: + document = ROOT / name + text = document.read_text(encoding="utf-8") + for link in re.findall(r"\]\(([^)]+)\)", text): + if "://" in link or link.startswith("#"): + continue + target = link.split("#", 1)[0] + with self.subTest(document=name, target=target): + self.assertTrue((document.parent / target).exists()) + + def test_required_job_is_unfiltered_and_unprivileged(self): + workflow = (ROOT / ".github/workflows/architecture.yml").read_text(encoding="utf-8") + for required in (" pull_request:", " merge_group:", "contents: read", + "name: architecture-contracts", "persist-credentials: false", + "fetch-depth: 0", "--base", "tools/i18n-contract/Cargo.toml --locked", + "scripts.tests.test_architecture_governance"): + with self.subTest(required=required): + self.assertIn(required, workflow) + for unsafe in ("pull_request_target:", "continue-on-error:", "paths:", "paths-ignore:"): + with self.subTest(unsafe=unsafe): + self.assertNotIn(unsafe, workflow) + + def test_review_and_activation_contracts_are_documented(self): + owners = (ROOT / ".github/CODEOWNERS").read_text(encoding="utf-8") + self.assertRegex(owners, r"(?m)^\*\s+@[A-Za-z0-9]") + template = (ROOT / ".github/PULL_REQUEST_TEMPLATE.md").read_text(encoding="utf-8") + self.assertIn("CONTRIBUTING.md", template) + self.assertIn("counterexample", template) + constraints = (ROOT / "docs/project-constraints.md").read_text(encoding="utf-8") + self.assertIn("do **not** activate GitHub merge protection", constraints) + self.assertIn("Empty/invalid budgets", constraints) + self.assertIn("an author cannot approve their own PR", constraints) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/xplat/check.sh b/scripts/xplat/check.sh new file mode 100644 index 0000000..f662d8b --- /dev/null +++ b/scripts/xplat/check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# 在 Windows 本机对 macOS / Linux 目标做 `cargo check`,不需要对应的 SDK。 +# +# 用法:scripts/xplat/check.sh [macos|linux|all] +# +# 这是跨目标元数据检查,可发现部分 cfg 与依赖声明不一致的问题 +# (比如 Cargo.toml 里按平台排除了某个 crate 而代码无条件使用)。 +# 使用占位 C 编译器与绑定,不能替代原生 runner 的真实 build/test。 +set -eu +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +targets=() +case "${1:-all}" in + macos) targets=(aarch64-apple-darwin) ;; + linux) targets=(x86_64-unknown-linux-gnu) ;; + all) targets=(aarch64-apple-darwin x86_64-unknown-linux-gnu) ;; + *) echo "usage: $0 [macos|linux|all]" >&2; exit 2 ;; +esac +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$repo/.target-xplat}" +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) fake="$(cygpath -w "$here/fake-cc.cmd")" ;; *) fake="$here/fake-cc.sh" ;; esac +status=0 +seed_media_stub() { + # zed `media` 的 build.rs 按宿主 cfg 决定是否生成 bindings.rs,Windows 宿主 + # 交叉到 macOS 时什么都不生成。把桩放进它的 OUT_DIR(首轮 check 后才存在)。 + local root="$CARGO_TARGET_DIR/$1/debug/build" dir + for dir in "$root"/media-*/out; do + [ -d "$dir" ] && [ ! -f "$dir/bindings.rs" ] && cp "$here/media-bindings-stub.rs" "$dir/bindings.rs" + done + # gpui_macos 用 `include_bytes!` 嵌 Metal 着色器库,同样只有 Mac 宿主能编; + # check 不执行它,空文件即可。 + for dir in "$root"/gpui_macos-*/out; do + [ -d "$dir" ] && [ ! -f "$dir/shaders.metallib" ] && : > "$dir/shaders.metallib" + done + return 0 +} +for target in "${targets[@]}"; do + echo "== cargo check --target $target" + env_target="$(echo "$target" | tr '-' '_')" + case "$target" in *apple-darwin) seed_media_stub "$target" ;; esac + env "CC_${env_target}=$fake" "CXX_${env_target}=$fake" "AR_${env_target}=$fake" \ + "PKG_CONFIG_ALLOW_CROSS=1" "RUST_FONTCONFIG_DLOPEN=1" \ + cargo check --offline --workspace --target "$target" "${@:2}" || status=$? +done +exit $status diff --git a/scripts/xplat/fake-cc.cmd b/scripts/xplat/fake-cc.cmd new file mode 100644 index 0000000..772ff84 --- /dev/null +++ b/scripts/xplat/fake-cc.cmd @@ -0,0 +1,17 @@ +@echo off +rem 假 C 编译器(Windows 版,见 fake-cc.sh 的说明):只给交叉 cargo check 用。 +rem 对每个 "-o " 产出空文件;--version / -E 打印占位并成功返回。 +setlocal EnableDelayedExpansion +set "prev=" +set "out=" +for %%A in (%*) do ( + set "arg=%%~A" + if "!arg!"=="--version" ( echo fake-cc 0.0 ^(nebula cross-check stub^) & exit /b 0 ) + if "!arg!"=="-dumpversion" ( echo 0.0 & exit /b 0 ) + if "!arg!"=="-dumpmachine" ( echo fake & exit /b 0 ) + if "!arg!"=="-E" ( echo #define __FAKE_CC__ 1 & exit /b 0 ) + if "!prev!"=="-o" set "out=!arg!" + set "prev=!arg!" +) +if defined out type nul > "!out!" +exit /b 0 diff --git a/scripts/xplat/fake-cc.sh b/scripts/xplat/fake-cc.sh new file mode 100644 index 0000000..999ea48 --- /dev/null +++ b/scripts/xplat/fake-cc.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# 假 C 编译器:只给 `cargo check --target <非本机>` 用。 +# +# 交叉 check 时 cargo 仍会跑 tree-sitter / ring / libsqlite3-sys / mlua-sys 等 +# crate 的 build script,它们用 cc-rs 编 C。本机没有 macOS SDK,真编不了; +# 但 check 不链接,只要 build script 成功、产出「一个文件」即可。这里对每个 +# `-o ` 产出一个空文件,其余参数忽略。绝不能用于 build/test。 +set -eu +out="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then out="$arg"; fi + case "$arg" in + --version|-v|-dumpversion|-dumpmachine) echo "fake-cc 0.0 (nebula cross-check stub)"; exit 0 ;; + -E) echo "#define __FAKE_CC__ 1"; exit 0 ;; + esac + prev="$arg" +done +if [ -n "$out" ]; then : > "$out"; fi +exit 0 diff --git a/scripts/xplat/media-bindings-stub.rs b/scripts/xplat/media-bindings-stub.rs new file mode 100644 index 0000000..93dcae7 --- /dev/null +++ b/scripts/xplat/media-bindings-stub.rs @@ -0,0 +1,50 @@ +// 交叉 `cargo check --target aarch64-apple-darwin` 用的 CoreMedia 绑定桩。 +// +// zed 的 `media` crate 在 build.rs 里用 bindgen + `xcrun` 从 macOS SDK 生成 +// `bindings.rs`,而且 build.rs 的 `#[cfg(target_os = "macos")]` 看的是**宿主**, +// 所以在 Windows 上交叉 check 时它什么都不生成。这里手写它实际用到的那几个 +// 符号(类型布局与 CoreMedia 一致),只为让 Rust 层通过类型检查。 +// 绝不能进入真实 macOS 构建——那里由 bindgen 产出真货。 + + +pub type CFStringRef = *const core::ffi::c_void; +pub type CVReturn = i32; +pub type CMItemIndex = i64; +pub type CMTimeValue = i64; +pub type CMTimeScale = i32; +pub type CMTimeFlags = u32; +pub type CMTimeEpoch = i64; +pub type CMVideoCodecType = u32; +pub type VTEncodeInfoFlags = u32; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct CMTime { + pub value: CMTimeValue, + pub timescale: CMTimeScale, + pub flags: CMTimeFlags, + pub epoch: CMTimeEpoch, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct CMSampleTimingInfo { + pub duration: CMTime, + pub presentationTimeStamp: CMTime, + pub decodeTimeStamp: CMTime, +} + +pub const VTEncodeInfoFlags_kVTEncodeInfo_Asynchronous: VTEncodeInfoFlags = 1; +pub const VTEncodeInfoFlags_kVTEncodeInfo_FrameDropped: VTEncodeInfoFlags = 2; +pub const kCVReturnSuccess: CVReturn = 0; +pub const kCVPixelFormatType_32BGRA: u32 = 0x4247_5241; // 'BGRA' +pub const kCVPixelFormatType_420YpCbCr8Planar: u32 = 0x7934_3230; // 'y420' +pub const kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange: u32 = 0x3432_3076; // '420v' +pub const kCVPixelFormatType_420YpCbCr8BiPlanarFullRange: u32 = 0x3432_3066; // '420f' +pub const kCMVideoCodecType_H264: CMVideoCodecType = 0x6176_6331; // 'avc1' + +unsafe extern "C" { + pub static kCMTimeInvalid: CMTime; + pub static kCMSampleAttachmentKey_NotSync: CFStringRef; + pub fn CMTimeMake(value: i64, timescale: i32) -> CMTime; +}