From 8ad5fa81b024a5cc08d57c9917d10a389fd7eff4 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 9 May 2026 21:48:00 +0800 Subject: [PATCH] =?UTF-8?q?build(step-1b):=20tools/check-widget-boundary.s?= =?UTF-8?q?h=20=E2=80=94=20Phase=20B4=20spec=20=C2=A71.4=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces the Step 1b §1.4 widget boundary invariant: widget logic (Widget impls + layout/paint/access_node methods) lives in crates/openpencil-shell-core/src/widgets/; shell-web's only widget-touching file is `widget_host.rs` and even there the only widget-method signature allowed is the `// glue:` marked paint dispatcher. Forward checks (no widget logic in shell-web/src/): - F1: `impl(<...>)?[[:space:]]+(ns::)*Widget[[:space:]]+for[[:space:]]` anywhere under shell-web/src/. Allows generic params + arbitrary namespace depth so `impl shell_core::widgets::Widget for X` is caught. No `// glue:` exemption — Widget impls have no place in shell-web period. - F2: `fn[[:space:]]+(layout|access_node)\(` anywhere under shell-web/src/. No exemption. - F3: `fn[[:space:]]+paint\(` under shell-web/src/, EXCEPT lines in widget_host.rs that ALSO carry `// glue:`. Tight exemption — the marker only blesses one specific signature, not arbitrary tagged lines. - F4: any line under shell-web/src/ mentioning both `openpencil_shell_core` AND `widgets`, except widget_host.rs. Catches direct + grouped `use` forms (e.g. `use openpencil_shell_core::{widgets::TreeWidget};`) plus path expressions. Multi-line braced `use` is out of scope (single-line policy in this crate). Reverse check (shell-core/src/widgets/ has all four impls): - R1: For each of {tree, prop_row, dropdown, text_input}, the file must exist AND, after stripping `//` line comments, must contain a live `impl Widget for X`. Block comments out of scope (line comments only in this directory). CI integration: - New "Verify Step 1b widget boundary (spec §1.4)" step in .github/workflows/rust-check.yml right after the existing "Verify Jian boundary invariants" step, gated to Linux runner (matches the jian-boundaries pattern). - Added `tools/check-jian-boundaries.sh` and `tools/check-widget-boundary.sh` to the rust-check.yml push + pull_request path filters so PRs editing only the checker still trigger CI. 7-test regression matrix (positive + 6 negative cases): - positive (real codebase) → PASS - generic `impl Widget for X` injected → FAIL F1 - direct `use openpencil_shell_core::widgets` outside host → FAIL F4 - `// glue:` tag on `impl Widget for X` line → FAIL F1 (exemption doesn't save it; only `fn paint` lines are exempted) - shell-core file replaced with `// stub` → FAIL R1 - grouped `use openpencil_shell_core::{widgets::TreeWidget};` → FAIL F4 - grouped `use openpencil_shell_core::{widgets};` → FAIL F4 - shell-core file body replaced with `// impl Widget for X { ... }` → FAIL R1 Codex iterate review: 5 rounds → GO. Round 1 BLOCK (greedy WidgetHost match), R2 BLOCK + 3 CONCERN (calls/imports unchecked, generic impls, broad exemption, filename-only count), R3 BLOCK + CONCERN (grouped imports, commented-out impls), R4 2 NITs (documentation parity), R5 GO clean. --- .github/workflows/rust-check.yml | 8 ++ tools/check-widget-boundary.sh | 159 +++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100755 tools/check-widget-boundary.sh diff --git a/.github/workflows/rust-check.yml b/.github/workflows/rust-check.yml index 8289ee059..d4c0eb037 100644 --- a/.github/workflows/rust-check.yml +++ b/.github/workflows/rust-check.yml @@ -9,6 +9,8 @@ on: - 'rust-toolchain.toml' - 'rustfmt.toml' - 'deny.toml' + - 'tools/check-jian-boundaries.sh' + - 'tools/check-widget-boundary.sh' - '.github/workflows/rust-check.yml' push: branches: [main, 'feat/rust-ification'] @@ -17,6 +19,8 @@ on: - 'Cargo.lock' - 'crates/**' - 'rust-toolchain.toml' + - 'tools/check-jian-boundaries.sh' + - 'tools/check-widget-boundary.sh' jobs: check: @@ -72,6 +76,10 @@ jobs: rustup target add aarch64-linux-android aarch64-apple-ios wasm32-unknown-unknown bash tools/check-jian-boundaries.sh + - name: Verify Step 1b widget boundary (spec §1.4) + if: runner.os == 'Linux' + run: bash tools/check-widget-boundary.sh + deny: name: cargo-deny (native) runs-on: ubuntu-latest diff --git a/tools/check-widget-boundary.sh b/tools/check-widget-boundary.sh new file mode 100755 index 000000000..56399cabb --- /dev/null +++ b/tools/check-widget-boundary.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# tools/check-widget-boundary.sh — Step 1b §1.4 widget boundary invariant. +# +# Per spec §1.4: widget logic (Widget impls, layout/paint/access_node) +# lives in `crates/openpencil-shell-core/src/widgets/`. shell-web is +# allowed exactly one glue file (`crates/openpencil-shell-web/src/ +# widget_host.rs`); the `// glue:` marker on its paint signature is +# the only line allowed to host a `fn paint(` that drives widgets. +# Any function or file in shell-web that pulls +# `openpencil_shell_core::widgets::*` must live in widget_host.rs +# (the spec's "any function pulling shell-core widgets" clause). +# +# Reverse direction: shell-core/src/widgets/ MUST contain four impl +# files (tree, prop_row, dropdown, text_input) AND each file must +# carry a real `impl Widget for X` — a stale file with the impl +# removed must not silently pass. +# +# Exit semantics: +# 0 PASS — both invariants hold. +# 1 FAIL — widget logic leaked into shell-web OR shell-core +# widget impl files are missing / empty. + +set -euo pipefail + +WEB_SRC="crates/openpencil-shell-web/src" +CORE_WIDGETS="crates/openpencil-shell-core/src/widgets" + +fail_lines=() + +# --------------------------------------------------------------------- +# Forward F1: no `impl Widget for X` (with optional generic params and +# arbitrary namespace segments) anywhere under shell-web/src/. +# Even widget_host.rs is not allowed to host a Widget impl — all real +# impls live in shell-core. The `// glue:` exemption is for the paint +# signature only, not for Widget trait impls (codex B4 R2 CONCERN-2). +# --------------------------------------------------------------------- +# `impl(<...>)?[[:space:]]+(ns::)*Widget[[:space:]]+for[[:space:]]`: +# - `(<[^>]+>)?` allows zero-or-one generic param block (`impl`) +# before the trait path (codex B4 R2 CONCERN-1). +# - `([[:alnum:]_]+::)*` allows zero or more namespace segments +# before `Widget` (codex B4 R1 BLOCK fix). +# - The `[[:space:]]+for[[:space:]]` tail rules out structs named +# WidgetHost / WidgetId. +impl_hits="$(grep -RInE \ + 'impl(<[^>]+>)?[[:space:]]+([[:alnum:]_]+::)*Widget[[:space:]]+for[[:space:]]' \ + "${WEB_SRC}" 2>/dev/null || true)" +if [ -n "${impl_hits}" ]; then + fail_lines+=("Forward F1: Widget trait impl in shell-web (must live in shell-core):" "${impl_hits}") +fi + +# --------------------------------------------------------------------- +# Forward F2: no `fn layout(` / `fn access_node(` anywhere under +# shell-web/src/. These are widget-trait method signatures and have +# no business in the platform crate. (Note: `fn paint(` is allowed +# only on the `// glue:` marked line in widget_host.rs — handled by +# F3 below to keep the exemption tight.) +# +# Introduced alongside F1/F3/F4 in codex B4 R2 (the original single- +# regex check was split into per-direction blocks with named +# findings); kept stable through R3 so no in-line "fixed by …" +# citation was needed. Listed here for parity with F1/F3/F4/R1. +# --------------------------------------------------------------------- +trait_method_hits="$(grep -RInE \ + 'fn[[:space:]]+(layout|access_node)\(' \ + "${WEB_SRC}" 2>/dev/null || true)" +if [ -n "${trait_method_hits}" ]; then + fail_lines+=("Forward F2: Widget trait method (layout / access_node) in shell-web:" "${trait_method_hits}") +fi + +# --------------------------------------------------------------------- +# Forward F3: `fn paint(` under shell-web is allowed ONLY when the +# line ALSO carries the `// glue:` marker AND the file is +# widget_host.rs. Tight exemption (codex B4 R2 CONCERN-2 — broad +# `// glue:` exemption could hide leaked impl/method lines if anyone +# tagged them; the F1/F2 checks above already scan everything, so +# F3's exemption only needs to bless the documented paint signature). +# --------------------------------------------------------------------- +paint_hits="$(grep -RInE \ + 'fn[[:space:]]+paint\(' \ + "${WEB_SRC}" 2>/dev/null || true)" +if [ -n "${paint_hits}" ]; then + illegal_paint="$(printf '%s\n' "${paint_hits}" \ + | grep -v '^crates/openpencil-shell-web/src/widget_host\.rs:.*// glue:' \ + || true)" + if [ -n "${illegal_paint}" ]; then + fail_lines+=("Forward F3: fn paint( outside widget_host.rs's // glue: line:" "${illegal_paint}") + fi +fi + +# --------------------------------------------------------------------- +# Forward F4: only widget_host.rs may import `openpencil_shell_core:: +# widgets` in any form. Catches: +# - `use openpencil_shell_core::widgets;` (direct) +# - `use openpencil_shell_core::widgets::Foo;` (direct) +# - `use openpencil_shell_core::{widgets};` (grouped) +# - `use openpencil_shell_core::{widgets::Foo};` (grouped) +# - `openpencil_shell_core::widgets::call()` (path expr) +# - Multi-line grouped imports are NOT caught (single-line +# scope only — code review enforces single-line use stmts in this +# crate; the cost-benefit doesn't justify a multi-line parser). +# +# Codex B4 R2 BLOCK-fix introduced F4; R3 BLOCK refined for grouped +# import forms — the previous regex required a literal `::widgets` +# right after `openpencil_shell_core`, missing the brace form. +# Solution: any line mentioning `openpencil_shell_core` AND `widgets` +# is a hit (single grep pass picks both patterns up). +# --------------------------------------------------------------------- +core_ref_hits="$(grep -RIn 'openpencil_shell_core' "${WEB_SRC}" 2>/dev/null || true)" +if [ -n "${core_ref_hits}" ]; then + # Filter to lines that ALSO mention `widgets`, then drop the + # widget_host.rs allowance. + illegal_imports="$(printf '%s\n' "${core_ref_hits}" \ + | grep 'widgets' \ + | grep -v '^crates/openpencil-shell-web/src/widget_host\.rs:' \ + || true)" + if [ -n "${illegal_imports}" ]; then + fail_lines+=("Forward F4: openpencil_shell_core::widgets reference outside widget_host.rs (covers direct + grouped use forms):" "${illegal_imports}") + fi +fi + +# --------------------------------------------------------------------- +# Reverse R1: each of the four expected widget impl files exists AND +# carries a real `impl Widget for X` line that is NOT a Rust +# line-comment. +# +# Codex B4 R2 CONCERN-3 introduced R1; R3 CONCERN refined the impl +# grep so a commented-out `// impl Widget for TextInput` stub does +# not silently satisfy the check. The stripping uses sed to drop +# `//`-prefixed content (after optional leading whitespace) before +# grepping; block comments (`/* … */`) are not handled because the +# Rust style in shell-core/src/widgets/ uses line comments only. +# --------------------------------------------------------------------- +required_widgets=("tree" "prop_row" "dropdown" "text_input") +for w in "${required_widgets[@]}"; do + file="${CORE_WIDGETS}/${w}.rs" + if [ ! -f "${file}" ]; then + fail_lines+=("Reverse R1: ${file} missing") + continue + fi + uncommented="$(sed -E 's@[[:space:]]*//.*@@' "${file}")" + if ! printf '%s\n' "${uncommented}" \ + | grep -qE 'impl(<[^>]+>)?[[:space:]]+([[:alnum:]_]+::)*Widget[[:space:]]+for[[:space:]]'; then + fail_lines+=("Reverse R1: ${file} has no live \`impl Widget for X\` (file exists but stale, or impl is commented out)") + fi +done + +# --------------------------------------------------------------------- +# Report. +# --------------------------------------------------------------------- +if [ "${#fail_lines[@]}" -gt 0 ]; then + printf 'FAIL: widget boundary check\n' >&2 + printf '\n' >&2 + for line in "${fail_lines[@]}"; do + printf '%s\n' "${line}" >&2 + done + exit 1 +fi + +echo "PASS: widget boundary check"