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:
1 parent
512add7f2f
commit
a3d25c913d
18 files changed
+1174
-100
No files matched your search
@@ -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
|
||||
@@ -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` 按词删除。
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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` 按词删除。
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user