build: enforce shared architecture and translation contracts

Adopt the shared source budgets, local dependency rules and tested Python
checkers. Add the architecture workflow and metadata-only cross-target
tools, and make existing filesystem test waits deterministic.
Document contributor checks in both unreleased notes without claiming
server-side branch protection or native platform validation.
This commit is contained in:
kuddev committed 2026-09-06 21:14:18 +08:00
1 parent 512add7f2f
commit a3d25c913d
18 files changed
+1174 -100

No files matched your search

+72
View File
@@ -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
+2
View File
@@ -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` 按词删除。
+37
View File
@@ -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"
+23
View File
@@ -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
+2
View File
@@ -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` 按词删除。
+23 -5
View File
@@ -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]
+83 -95
View File
@@ -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<PathBuf>) {
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<PathBuf>) {
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::<Vec<_>>();
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::<usize>().unwrap());
},
"root" => assert!(roots.insert(fields[1]), "duplicate source root"),
name => {
let count = fields[1].parse::<usize>().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);
}
}
View File
Whitespace-only changes.
+139
View File
@@ -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
+134
View File
@@ -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
+78
View File
@@ -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())
+165
View File
@@ -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()
@@ -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()
@@ -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()
+44
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
@echo off
rem 假 C 编译器(Windows 版,见 fake-cc.sh 的说明):只给交叉 cargo check 用。
rem 对每个 "-o <path>" 产出空文件;--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
+20
View File
@@ -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 <path>` 产出一个空文件,其余参数忽略。绝不能用于 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
+50
View File
@@ -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;
}