mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 10:36:05 -10:00
wip: unfinished review fix-up (interrupted by the session usage limit)
Uncommitted edits from the fix-up stage, saved so nothing is lost. Not compiled or tested after these edits; the next pass reviews and finishes them. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
1 parent
f1fba5d145
commit
7c8319eb1c
13 files changed
+411
-27
No files matched your search
@@ -376,10 +376,12 @@ jobs:
|
||||
RUST_MIN_STACK: "8388608"
|
||||
# nextest fails when the filter selects no test (`--no-tests=fail`),
|
||||
# so moving these modules to another crate cannot turn this step into
|
||||
# a silent 0-test pass. Name every package that owns safety tests
|
||||
# explicitly; add `-p codewhale-runtime` when those modules move there.
|
||||
# a silent 0-test pass. It does not catch a step that merely shrinks,
|
||||
# so name every package that owns safety tests explicitly:
|
||||
# codewhale-runtime owns `safe_label` (its hostile-authority test)
|
||||
# since RS-2; add the next package when another safety module moves.
|
||||
run: |
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-tui --lib --locked --no-tests=fail -E 'test(auto_review) | test(authority) | test(sandbox)'
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail -E 'test(auto_review) | test(authority) | test(sandbox)'
|
||||
sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-execpolicy --locked
|
||||
|
||||
lint:
|
||||
@@ -458,9 +460,20 @@ jobs:
|
||||
run: python3 scripts/check-provider-registry.py
|
||||
- name: Check command-contract prototype boundary
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
# The runtime -> UI ratchet baseline is compared with the one at the
|
||||
# PR base too, so a hand-edited JSON cannot raise it.
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
python3 scripts/test_check_command_crate_boundaries.py
|
||||
python3 scripts/check-command-crate-boundaries.py
|
||||
baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}"
|
||||
if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then
|
||||
git fetch --no-tags origin "${baseline}"
|
||||
python3 scripts/check-command-crate-boundaries.py --baseline-ref "${baseline}"
|
||||
else
|
||||
python3 scripts/check-command-crate-boundaries.py
|
||||
fi
|
||||
- name: Check command migration manifest
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
env:
|
||||
|
||||
@@ -229,3 +229,50 @@ pub fn normalize_hex_rgb_color(value: &str) -> Option<String> {
|
||||
let (r, g, b) = parse_hex_rgb(value)?;
|
||||
Some(format!("#{r:02x}{g:02x}{b:02x}"))
|
||||
}
|
||||
|
||||
// Theme ids and hex parsing are what the runtime links (without `ratatui`),
|
||||
// so their tests must not sit behind the `ratatui` gate that `tests.rs` needs.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_rgb_parses_with_or_without_hash_and_rejects_malformed_input() {
|
||||
assert_eq!(parse_hex_rgb("#1a1B26"), Some((26, 27, 38)));
|
||||
assert_eq!(parse_hex_rgb(" 1a1b26 "), Some((26, 27, 38)));
|
||||
assert_eq!(
|
||||
normalize_hex_rgb_color("#1A1B26").as_deref(),
|
||||
Some("#1a1b26")
|
||||
);
|
||||
for bad in ["#123", "#zzzzzz", "", "#1a1b2", "#1a1b267", "#+1a1b2"] {
|
||||
assert_eq!(parse_hex_rgb(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_names_normalize_aliases_and_reject_unknown_names() {
|
||||
assert_eq!(normalize_theme_name(" Default "), Some("system"));
|
||||
assert_eq!(normalize_theme_name("whale"), Some("dark"));
|
||||
assert_eq!(normalize_theme_name("b&w"), Some("grayscale"));
|
||||
assert_eq!(normalize_theme_name("not-a-theme"), None);
|
||||
for id in SELECTABLE_THEMES {
|
||||
assert_eq!(ThemeId::from_name(id.name()), Some(*id), "{}", id.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_theme_selector_accepts_only_bounded_ascii_slugs() {
|
||||
assert_eq!(
|
||||
normalize_user_theme_selector("custom: My_Theme-2 "),
|
||||
Ok(Some("custom:my_theme-2".to_string()))
|
||||
);
|
||||
assert_eq!(normalize_user_theme_selector("dark"), Ok(None));
|
||||
for bad in ["custom:", "custom:a/b", "custom:../x", "custom:ünïcode"] {
|
||||
assert!(normalize_user_theme_selector(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(normalize_user_theme_selector(&format!("custom:{}", "a".repeat(65))).is_err());
|
||||
assert_eq!(normalize_theme_setting("whale").as_deref(), Ok("dark"));
|
||||
assert!(normalize_theme_setting("custom:../x").is_err());
|
||||
assert!(normalize_theme_setting("nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,25 @@ edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Headless Codewhale runtime: engine, tools, sessions and stores, with no terminal UI dependency"
|
||||
description = "Headless Codewhale runtime, split out of the terminal UI one module at a time; no terminal UI dependency"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
# Test helpers (process-global state guards, snapshots, diff renders) that
|
||||
# the TUI's tests use. Only the TUI's [dev-dependencies] enables it, so it
|
||||
# never reaches a release build (resolver 2). Every item behind it is `pub`.
|
||||
# the TUI's tests use. It only *adds* `pub` items; it changes no behavior.
|
||||
# The TUI enables it from [dev-dependencies] only, so `-p codewhale-cli` /
|
||||
# `-p codewhale-tui` release builds never see it; a `--workspace
|
||||
# --all-features` build does, which is harmless for helpers.
|
||||
#
|
||||
# `test-thread-scoped-state` is the one behavior switch: it makes the retry
|
||||
# banner and the provider-wide `Retry-After` pause per-thread so concurrent
|
||||
# tests cannot see each other's HTTP retries (see `retry_status`). It takes
|
||||
# effect only with debug assertions, so no optimized build ever carries it,
|
||||
# even under `--all-features`.
|
||||
[features]
|
||||
test-support = []
|
||||
test-thread-scoped-state = []
|
||||
|
||||
[lib]
|
||||
name = "codewhale_runtime"
|
||||
|
||||
@@ -84,21 +84,28 @@ pub fn host() -> &'static dyn HostTerminal {
|
||||
/// was on when the guard was created.
|
||||
#[must_use = "raw mode is resumed when the guard drops"]
|
||||
pub struct RawModeSuspension {
|
||||
/// The host that suspended raw mode resumes it.
|
||||
host: &'static dyn HostTerminal,
|
||||
resume: bool,
|
||||
}
|
||||
|
||||
/// Leave raw mode around an interactive child; resume it when the returned
|
||||
/// guard drops, only if it was on to begin with.
|
||||
pub fn suspend_raw_mode() -> RawModeSuspension {
|
||||
suspend_raw_mode_on(host())
|
||||
}
|
||||
|
||||
fn suspend_raw_mode_on(host: &'static dyn HostTerminal) -> RawModeSuspension {
|
||||
RawModeSuspension {
|
||||
resume: host().suspend_raw_mode(),
|
||||
host,
|
||||
resume: host.suspend_raw_mode(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RawModeSuspension {
|
||||
fn drop(&mut self) {
|
||||
if self.resume {
|
||||
host().resume_raw_mode();
|
||||
self.host.resume_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +113,45 @@ impl Drop for RawModeSuspension {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// Records raw-mode calls. Each test owns its own `static` instance, so
|
||||
/// they never share counters.
|
||||
struct CountingHost {
|
||||
raw: AtomicBool,
|
||||
suspends: AtomicUsize,
|
||||
resumes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CountingHost {
|
||||
const fn new(raw: bool) -> Self {
|
||||
Self {
|
||||
raw: AtomicBool::new(raw),
|
||||
suspends: AtomicUsize::new(0),
|
||||
resumes: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTerminal for CountingHost {
|
||||
fn suspend_raw_mode(&self) -> bool {
|
||||
self.suspends.fetch_add(1, Ordering::SeqCst);
|
||||
self.raw.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn resume_raw_mode(&self) {
|
||||
self.resumes.fetch_add(1, Ordering::SeqCst);
|
||||
self.raw.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn notify_model(&self, _title: &str, _body: Option<&str>) -> &'static str {
|
||||
"unused"
|
||||
}
|
||||
|
||||
fn set_terminal_focused(&self, _focused: bool) {}
|
||||
|
||||
fn apply_notification_settings(&self, _config: &NotificationsConfig) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_host_suspension_is_a_no_op() {
|
||||
@@ -114,4 +160,31 @@ mod tests {
|
||||
let guard = suspend_raw_mode();
|
||||
assert!(!guard.resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_mode_that_was_on_is_resumed_exactly_once_when_the_guard_drops() {
|
||||
static HOST: CountingHost = CountingHost::new(true);
|
||||
let guard = suspend_raw_mode_on(&HOST);
|
||||
assert!(
|
||||
!HOST.raw.load(Ordering::SeqCst),
|
||||
"raw mode is off while suspended"
|
||||
);
|
||||
assert_eq!(HOST.resumes.load(Ordering::SeqCst), 0);
|
||||
drop(guard);
|
||||
assert!(
|
||||
HOST.raw.load(Ordering::SeqCst),
|
||||
"raw mode is back on after the guard"
|
||||
);
|
||||
assert_eq!(HOST.suspends.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(HOST.resumes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_mode_that_was_off_is_never_turned_on_by_the_guard() {
|
||||
static HOST: CountingHost = CountingHost::new(false);
|
||||
drop(suspend_raw_mode_on(&HOST));
|
||||
assert_eq!(HOST.suspends.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(HOST.resumes.load(Ordering::SeqCst), 0);
|
||||
assert!(!HOST.raw.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ impl RetryState {
|
||||
|
||||
/// Lazy-init the cell on first read so callers don't have to initialize
|
||||
/// process-wide state at boot.
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
|
||||
fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
static STATE: OnceLock<Mutex<RetryState>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
@@ -96,7 +96,7 @@ fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
f(&mut state)
|
||||
}
|
||||
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
|
||||
fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
static STATE: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
@@ -115,7 +115,14 @@ fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
/// provider pause into another test's assertions. Scoping by thread removes the
|
||||
/// race at its source rather than asking every future test that happens to
|
||||
/// perform HTTP to remember a lock.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
///
|
||||
/// This changes behavior (a `Retry-After` pause no longer holds across tokio
|
||||
/// worker threads), so it is *not* part of `test-support`, which only adds
|
||||
/// helpers. The TUI's tests opt in with `test-thread-scoped-state`, and even
|
||||
/// then only a build with debug assertions gets it: an optimized build, e.g.
|
||||
/// `cargo build --release --workspace --all-features`, keeps the process-wide
|
||||
/// pause.
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
#[allow(clippy::type_complexity)]
|
||||
static STATE: OnceLock<Mutex<std::collections::HashMap<std::thread::ThreadId, RetryState>>> =
|
||||
@@ -129,7 +136,7 @@ fn with_state<R>(f: impl FnOnce(&mut RetryState) -> R) -> R {
|
||||
.or_insert(RetryState::Idle))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
|
||||
fn with_rate_limit<R>(f: impl FnOnce(&mut Option<Instant>) -> R) -> R {
|
||||
#[allow(clippy::type_complexity)]
|
||||
static STATE: OnceLock<
|
||||
|
||||
@@ -142,6 +142,7 @@ codewhale-build-support = { path = "../build-support", version = "0.10.0" }
|
||||
[dev-dependencies]
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.0", features = [
|
||||
"test-support",
|
||||
"test-thread-scoped-state",
|
||||
] }
|
||||
codewhale-models = { path = "../models", version = "0.10.0", features = [
|
||||
"test-support",
|
||||
|
||||
@@ -177,9 +177,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_method_off_still_reports_success_to_the_model() {
|
||||
async fn the_model_sees_success_whatever_the_host_did() {
|
||||
// The description promises a *silent* no-op: whatever the host did
|
||||
// with the notification, the model sees success (nothing to retry).
|
||||
// No host is installed in this test binary, so this is the no-host
|
||||
// receipt; `method = "off"` through the TUI host is covered by
|
||||
// `tui::notifications` (`notify_model_honors_the_installed_off_method`)
|
||||
// and `tui::ui::terminal` (`tui_host_routes_the_notify_tool_through_the_installed_method`).
|
||||
|
||||
let result = NotifyTool
|
||||
.execute(json!({"title": "done"}), &ctx())
|
||||
|
||||
@@ -1132,6 +1132,7 @@ mod tests {
|
||||
/// the installed method decides suppression before any sink write.
|
||||
#[test]
|
||||
fn configured_method_off_silences_the_tool_emission() {
|
||||
let _lock = env_lock();
|
||||
let _restore = ConfiguredMethodRestore::capture();
|
||||
install_configured_method(Method::Off);
|
||||
|
||||
@@ -1155,6 +1156,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The function the host port calls for the `notify` tool must read the
|
||||
/// installed method: `[notifications] method = "off"` silences the tool
|
||||
/// and the receipt says so. A `notify_model` that ignored
|
||||
/// `configured_method()` would pass the test above but fail this one.
|
||||
#[test]
|
||||
fn notify_model_honors_the_installed_off_method() {
|
||||
let _lock = env_lock();
|
||||
let _method_restore = ConfiguredMethodRestore::capture();
|
||||
let _gate_restore = NotificationGateRestore::capture();
|
||||
let previous_condition = current_attention_condition();
|
||||
// `condition = "always"` so the attention policy (checked first) lets
|
||||
// the call reach the method check whatever the test runner's focus.
|
||||
let off: crate::config::Config = toml::from_str(
|
||||
r#"
|
||||
[notifications]
|
||||
method = "off"
|
||||
condition = "always"
|
||||
"#,
|
||||
)
|
||||
.expect("method=off config should parse");
|
||||
let _ = settings(&off);
|
||||
|
||||
let receipt = notify_model("done", Some("all tests pass"));
|
||||
|
||||
install_attention_condition(previous_condition);
|
||||
assert_eq!(receipt, DeliveryOutcome::SuppressedByMethod.receipt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_whale_is_static_when_focused_or_motion_disabled() {
|
||||
let _guard = prefix_lock();
|
||||
@@ -1592,6 +1621,8 @@ mod tests {
|
||||
/// pair can never silently fall back to `Auto`.
|
||||
#[test]
|
||||
fn configured_method_round_trips_every_variant() {
|
||||
// Serialized with the tests that deliver through the installed method.
|
||||
let _lock = env_lock();
|
||||
let _restore = ConfiguredMethodRestore::capture();
|
||||
for method in [
|
||||
Method::Auto,
|
||||
|
||||
@@ -1065,3 +1065,37 @@ impl crate::host_terminal::HostTerminal for TuiHostTerminal {
|
||||
pub(crate) fn install_host_terminal() {
|
||||
let _ = crate::host_terminal::install(Box::new(TuiHostTerminal));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_terminal_tests {
|
||||
use super::TuiHostTerminal;
|
||||
use crate::host_terminal::HostTerminal;
|
||||
use crate::notify::DeliveryOutcome;
|
||||
use crate::tui::notifications::{configured_method, install_configured_method};
|
||||
|
||||
/// The `notify` tool reaches delivery only through the installed host:
|
||||
/// the TUI's host must hand the model's text to the delivery path that
|
||||
/// honors the installed method, so `method = "off"` stays silent.
|
||||
#[test]
|
||||
fn tui_host_routes_the_notify_tool_through_the_installed_method() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let previous_method = configured_method();
|
||||
let config = |text: &str| -> crate::config::Config {
|
||||
toml::from_str(text).expect("notifications config should parse")
|
||||
};
|
||||
// Settings reach the host the way the composition root sends them;
|
||||
// `condition = "always"` so the attention policy (checked first)
|
||||
// lets the call reach the method check whatever the runner's focus.
|
||||
TuiHostTerminal.apply_notification_settings(
|
||||
&config("[notifications]\nmethod = \"off\"\ncondition = \"always\"\n")
|
||||
.notifications_config(),
|
||||
);
|
||||
|
||||
let receipt = TuiHostTerminal.notify_model("done", None);
|
||||
|
||||
TuiHostTerminal
|
||||
.apply_notification_settings(&config("[notifications]\n").notifications_config());
|
||||
install_configured_method(previous_method);
|
||||
assert_eq!(receipt, DeliveryOutcome::SuppressedByMethod.receipt());
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -133,10 +133,19 @@ boundary has held since v0.9.1):
|
||||
- **`crates/models`** - Provider request/response models and the offline model
|
||||
metadata catalog.
|
||||
- **`crates/palette`** - Colour tokens, themes, and contrast math for the
|
||||
terminal UI.
|
||||
terminal UI. Its `ratatui` feature (on by default) gates everything that
|
||||
renders; theme ids, setting normalizers and hex parsing compile without it,
|
||||
which is how the runtime links it.
|
||||
- **`crates/paths`** - User-scoped runtime path authority (`CODEWHALE_HOME`
|
||||
and platform home resolution).
|
||||
- **`crates/protocol`** - Request/response framing and protocol types.
|
||||
- **`crates/runtime`** - `codewhale-runtime`, the headless runtime being
|
||||
split out of `crates/tui` (`docs/design/TUI_DECONSTRUCTION.md`). Today it
|
||||
holds the leaf modules that moved first (retry status, safe labels, sleep
|
||||
guard, session tree, ...) and `host_terminal`, the one port through which
|
||||
runtime code asks the terminal UI for a terminal effect. It never depends on
|
||||
the TUI, `ratatui` or `crossterm`; `scripts/check-command-crate-boundaries.py`
|
||||
enforces that and ratchets the runtime -> UI references still in `crates/tui`.
|
||||
- **`crates/secrets`** - OS keyring integration for API key storage, plus the
|
||||
shared output sanitizer (`sanitize`) and redaction (`redact`) that UI and
|
||||
runtime code both call.
|
||||
|
||||
@@ -39,6 +39,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
@@ -219,7 +220,12 @@ def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation
|
||||
def cargo_tree_packages(package: str) -> set[str]:
|
||||
"""Package names in `cargo tree -p <package> -e normal,build` (per-package features)."""
|
||||
result = subprocess.run(
|
||||
["cargo", "tree", "-p", package, "-e", "normal,build", "--prefix", "none", "--locked"],
|
||||
# `--target all`: a dependency behind `cfg(windows)` counts too, not
|
||||
# only the ones the host target resolves.
|
||||
[
|
||||
"cargo", "tree", "-p", package, "-e", "normal,build", "--target", "all",
|
||||
"--prefix", "none", "--locked",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -336,9 +342,9 @@ def load_runtime_ratchet():
|
||||
return module
|
||||
|
||||
|
||||
def check_runtime_ratchet() -> list[BoundaryViolation]:
|
||||
def check_runtime_ratchet(baseline_ref: str | None = None) -> list[BoundaryViolation]:
|
||||
"""Runtime -> UI references may only go down (docs/design/TUI_DECONSTRUCTION.md, runtime split)."""
|
||||
problems = load_runtime_ratchet().check()
|
||||
problems = load_runtime_ratchet().check(baseline_ref=baseline_ref)
|
||||
return [BoundaryViolation("runtime-ratchet", "scripts/runtime-boundary-baseline.json", p) for p in problems]
|
||||
|
||||
|
||||
@@ -346,6 +352,7 @@ def run_checks(
|
||||
metadata: dict | None = None,
|
||||
tree: Callable[[str], set[str]] | None = None,
|
||||
ratchet: bool = True,
|
||||
baseline_ref: str | None = None,
|
||||
) -> list[BoundaryViolation]:
|
||||
"""Run all boundary checks; return the collected violations."""
|
||||
graph = dependency_graph(metadata) if metadata is not None else dependency_graph(
|
||||
@@ -359,13 +366,18 @@ def run_checks(
|
||||
if rule.source_dir is not None:
|
||||
violations.extend(check_source_dir(rule))
|
||||
if ratchet:
|
||||
violations.extend(check_runtime_ratchet())
|
||||
violations.extend(check_runtime_ratchet(baseline_ref))
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
del argv # reserved for future flags (e.g. --update); check is the default
|
||||
violations = run_checks()
|
||||
parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0])
|
||||
parser.add_argument(
|
||||
"--baseline-ref",
|
||||
help="git revision (CI: the PR base) whose runtime ratchet baseline may not be exceeded",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
violations = run_checks(baseline_ref=args.baseline_ref)
|
||||
if violations:
|
||||
print("[command-crate-boundaries] FAIL", file=sys.stderr)
|
||||
for violation in violations:
|
||||
|
||||
@@ -35,7 +35,11 @@ The baseline is `scripts/runtime-boundary-baseline.json`. `--check` (default)
|
||||
fails on any increased count or new key and prints `file:line` for it; it also
|
||||
fails when a count dropped and the baseline was not lowered in the same change,
|
||||
so the baseline stays honest. `--update` rewrites the baseline and refuses to
|
||||
raise any count.
|
||||
raise any count. `--baseline-ref REV` (CI passes the PR base) also fails when
|
||||
the committed baseline holds any count above the baseline at REV, so a
|
||||
hand-edited JSON cannot raise the ratchet either.
|
||||
|
||||
`super::` chains that climb to the crate root count like `crate::` paths.
|
||||
|
||||
See docs/design/TUI_DECONSTRUCTION.md (runtime split, ratchet).
|
||||
"""
|
||||
@@ -47,6 +51,7 @@ import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -54,7 +59,8 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TUI_SRC = REPO_ROOT / "crates" / "tui" / "src"
|
||||
RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src"
|
||||
BASELINE = REPO_ROOT / "scripts" / "runtime-boundary-baseline.json"
|
||||
BASELINE_REPO_PATH = "scripts/runtime-boundary-baseline.json"
|
||||
BASELINE = REPO_ROOT / BASELINE_REPO_PATH
|
||||
|
||||
SEEDS = (
|
||||
"core",
|
||||
@@ -288,6 +294,9 @@ class Crate:
|
||||
|
||||
USE_RE = re.compile(r"\buse\s+crate::")
|
||||
PATH_RE = re.compile(r"(?<![\$\w])crate::(\w+)")
|
||||
# `super::super::tui::x` reaches the crate root just like `crate::tui::x`.
|
||||
SUPER_RE = re.compile(r"(?<![\$\w:])((?:super\s*::\s*)+)(\{|\w+)")
|
||||
INLINE_MOD_RE = re.compile(r"\bmod\s+\w+\s*\{")
|
||||
UILIB_RE = re.compile(r"(?<![\w:])(ratatui|crossterm|codewhale_tui)(?:::|\s*;|\s*\{)")
|
||||
DOC_LINK_RE = re.compile(r"\[`?crate::(tui|commands)\b")
|
||||
|
||||
@@ -368,6 +377,43 @@ def file_is_test(rel: str, exact: set[str], prefixes: set[str]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def file_depth(rel: str) -> int:
|
||||
"""Module depth of a source file below the crate root (`lib.rs` is 0)."""
|
||||
parts = rel.split("/")
|
||||
if len(parts) == 1:
|
||||
return 0 if parts[0] in ("lib.rs", "main.rs") else 1
|
||||
return len(parts) - 1 if parts[-1] == "mod.rs" else len(parts)
|
||||
|
||||
|
||||
def inline_mod_spans(code: str) -> list[tuple[int, int]]:
|
||||
"""Byte ranges of inline `mod name { ... }` bodies (each adds one level)."""
|
||||
return [(m.end() - 1, match_brace(code, m.end() - 1)) for m in INLINE_MOD_RE.finditer(code)]
|
||||
|
||||
|
||||
def super_root_targets(code: str, rel: str) -> list[tuple[int, str]]:
|
||||
"""`(offset, first segment)` for every `super::` chain that climbs to the crate root.
|
||||
|
||||
A chain of k `super`s written at module depth d (file depth plus the
|
||||
enclosing inline `mod` blocks) names the crate root when k == d; a
|
||||
shorter chain stays inside the module and is not a cross-module edge.
|
||||
"""
|
||||
base = file_depth(rel)
|
||||
spans = inline_mod_spans(code)
|
||||
out: list[tuple[int, str]] = []
|
||||
for m in SUPER_RE.finditer(code):
|
||||
k = m.group(1).count("super")
|
||||
depth = base + sum(1 for a, b in spans if a < m.start() < b)
|
||||
if k != depth:
|
||||
continue
|
||||
if m.group(2) == "{":
|
||||
end = match_brace(code, m.end() - 1)
|
||||
for _, path in use_leaves(code[m.end() : end]):
|
||||
out.append((m.start(), path[0]))
|
||||
else:
|
||||
out.append((m.start(), m.group(2)))
|
||||
return out
|
||||
|
||||
|
||||
def collect_refs(crate: Crate, all_modules: set[str], prefix: str) -> list[Ref]:
|
||||
exact, prefixes = test_file_set(crate)
|
||||
refs: list[Ref] = []
|
||||
@@ -409,6 +455,13 @@ def collect_refs(crate: Crate, all_modules: set[str], prefix: str) -> list[Ref]:
|
||||
continue
|
||||
line = code.count("\n", 0, m.start()) + 1
|
||||
refs.append(Ref(kind_at(m.start()), mod, target_of(m.group(1)), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160]))
|
||||
for pos, name in super_root_targets(code, rel):
|
||||
# `pub(in super::super)` and glob imports name no item; only a
|
||||
# known module or crate-root binding is an edge.
|
||||
if name not in all_modules and name not in crate.root_names:
|
||||
continue
|
||||
line = code.count("\n", 0, pos) + 1
|
||||
refs.append(Ref(kind_at(pos), mod, target_of(name), f"{prefix}/{rel}", line, lines[line - 1].strip()[:160]))
|
||||
return refs
|
||||
|
||||
|
||||
@@ -533,6 +586,41 @@ def load_baseline(path: Path = BASELINE) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_baseline_at_ref(ref: str, root: Path = REPO_ROOT) -> dict | None:
|
||||
"""The baseline as committed at `ref`; None when it did not exist yet."""
|
||||
commit = subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
|
||||
cwd=root, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if commit.returncode != 0:
|
||||
raise ValueError(f"baseline ref {ref!r} is unavailable: {commit.stderr.strip()}")
|
||||
shown = subprocess.run(
|
||||
["git", "show", f"{ref}:{BASELINE_REPO_PATH}"],
|
||||
cwd=root, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if shown.returncode != 0:
|
||||
return None
|
||||
return json.loads(shown.stdout)
|
||||
|
||||
|
||||
def baseline_raises(previous: dict, current: dict) -> list[str]:
|
||||
"""Counts the committed baseline holds above the baseline it replaces.
|
||||
|
||||
The local `--update` refuses to raise, but the JSON is an ordinary file:
|
||||
without this comparison a change could add a reference and bump the
|
||||
count by hand in the same commit, and `check` would pass.
|
||||
"""
|
||||
raised: list[str] = []
|
||||
prev_counts = previous.get("counts", {})
|
||||
for cat in CATEGORIES:
|
||||
prev = prev_counts.get(cat, {})
|
||||
for key, n in current.get("counts", {}).get(cat, {}).items():
|
||||
b = prev.get(key, 0)
|
||||
if n > b:
|
||||
raised.append(f"{cat} {key}: {b} -> {n}" + (" (new pair)" if key not in prev else ""))
|
||||
return raised
|
||||
|
||||
|
||||
def baseline_document(report: Report) -> dict:
|
||||
return {
|
||||
"_comment": (
|
||||
@@ -545,13 +633,34 @@ def baseline_document(report: Report) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def check(path: Path = BASELINE, report: Report | None = None) -> list[str]:
|
||||
"""Return violation lines (empty when the ratchet holds)."""
|
||||
def check(
|
||||
path: Path = BASELINE,
|
||||
report: Report | None = None,
|
||||
baseline_ref: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return violation lines (empty when the ratchet holds).
|
||||
|
||||
With `baseline_ref` (CI passes the PR base), the committed baseline must
|
||||
also be no higher than the one at that revision.
|
||||
"""
|
||||
report = report or build_report()
|
||||
if not path.is_file():
|
||||
return [f"missing baseline {path.relative_to(REPO_ROOT)}; run with --update"]
|
||||
rises, drops = compare(load_baseline(path), report)
|
||||
problems = []
|
||||
if baseline_ref:
|
||||
try:
|
||||
previous = load_baseline_at_ref(baseline_ref)
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
return [str(error)]
|
||||
if previous is not None:
|
||||
raised = baseline_raises(previous, load_baseline(path))
|
||||
if raised:
|
||||
problems.append(
|
||||
f"the baseline was raised relative to {baseline_ref} (counts only go down; "
|
||||
"remove the reference instead of editing the JSON):"
|
||||
)
|
||||
problems.extend(f" {r}" for r in raised)
|
||||
rises, drops = compare(load_baseline(path), report)
|
||||
if rises:
|
||||
problems.append("runtime -> UI references rose (the ratchet only goes down):")
|
||||
problems.extend(f" {r}" for r in rises)
|
||||
@@ -570,6 +679,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
mode.add_argument("--check", action="store_true", help="enforce the ratchet (default)")
|
||||
mode.add_argument("--update", action="store_true", help="lower the baseline to the current counts")
|
||||
mode.add_argument("--report", action="store_true", help="print counts and closure as JSON")
|
||||
parser.add_argument(
|
||||
"--baseline-ref",
|
||||
help="git revision whose committed baseline the current one may not exceed (CI: the PR base)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = build_report()
|
||||
@@ -593,7 +706,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
BASELINE.write_text(json.dumps(baseline_document(report), indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[runtime-boundary] baseline written: {totals(report.counts)}")
|
||||
return 0
|
||||
problems = check(report=report)
|
||||
problems = check(report=report, baseline_ref=args.baseline_ref)
|
||||
if problems:
|
||||
print("[runtime-boundary] FAIL", file=sys.stderr)
|
||||
for p in problems:
|
||||
|
||||
@@ -213,6 +213,37 @@ class RatchetTests(unittest.TestCase):
|
||||
rises, drops = self.graph.compare({"counts": {"prod": {"core|tui": 3}}}, report)
|
||||
self.assertEqual((rises, drops), ([], ["prod core|tui: 3 -> 2"]))
|
||||
|
||||
def test_super_chains_that_reach_the_root_count(self) -> None:
|
||||
report = self.report({
|
||||
"lib.rs": "mod core; mod tui;\n",
|
||||
"core/mod.rs": (
|
||||
"mod inner;\n"
|
||||
"fn f() { super::tui::a(); }\n"
|
||||
"pub(in super::super) fn g() {}\n"
|
||||
"#[cfg(test)]\nmod tests { use super::super::{tui::B, x}; use super::*; }\n"
|
||||
),
|
||||
# Depth 2: two `super`s reach the root, one stays inside `core`.
|
||||
"core/inner.rs": "use super::super::tui as ui;\nfn f() { super::tui::local(); }\n",
|
||||
"tui.rs": "",
|
||||
})
|
||||
self.assertEqual(report.counts["prod"], {"core|tui": 2})
|
||||
self.assertEqual(report.counts["test"], {"core|tui": 1})
|
||||
|
||||
def test_hand_raised_baseline_fails_against_the_base(self) -> None:
|
||||
previous = {"counts": {"prod": {"tools|tui": 8}}}
|
||||
self.assertEqual(self.graph.baseline_raises(previous, previous), [])
|
||||
self.assertEqual(
|
||||
self.graph.baseline_raises(previous, {"counts": {"prod": {"tools|tui": 7}}}), []
|
||||
)
|
||||
self.assertEqual(
|
||||
self.graph.baseline_raises(previous, {"counts": {"prod": {"tools|tui": 9}}}),
|
||||
["prod tools|tui: 8 -> 9"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.graph.baseline_raises(previous, {"counts": {"test": {"core|tui": 1}}}),
|
||||
["test core|tui: 0 -> 1 (new pair)"],
|
||||
)
|
||||
|
||||
def test_checked_in_baseline_holds(self) -> None:
|
||||
self.assertEqual(self.graph.check(), [])
|
||||
|
||||
|
||||
Reference in new issue
Block a user