diff --git a/.github/actions/setup-lint-toolchain/action.yml b/.github/actions/setup-lint-toolchain/action.yml index a6129833..2e7666f9 100644 --- a/.github/actions/setup-lint-toolchain/action.yml +++ b/.github/actions/setup-lint-toolchain/action.yml @@ -4,7 +4,7 @@ inputs: sc-lint-version: description: Released sc-lint version to install required: false - default: "0.4.0" + default: "0.5.0" cargo-deny-version: description: Pinned cargo-deny version required: false diff --git a/.github/actions/setup-sc-lint/action.yml b/.github/actions/setup-sc-lint/action.yml index 7f10100b..55bcc929 100644 --- a/.github/actions/setup-sc-lint/action.yml +++ b/.github/actions/setup-sc-lint/action.yml @@ -9,7 +9,7 @@ inputs: version: description: Released sc-lint version to install (deliberate ecosystem pin) required: false - default: "0.4.0" + default: "0.5.0" repository: description: GitHub repository slug hosting sc-lint releases required: false @@ -93,10 +93,13 @@ runs: run: | set -euo pipefail version_json="$(sc-lint version --json)" + # 0.5.0+ reports data.version; 0.4.x reported data.crate_version. + actual="$(jq -r '.data.version // .data.crate_version // empty' <<<"${version_json}")" jq -e --arg expected "${SC_LINT_VERSION}" \ - '.ok == true and .data.crate_version == $expected' \ + '.ok == true and ((.data.version // .data.crate_version) == $expected)' \ <<<"${version_json}" >/dev/null || { - echo "sc-lint setup: version contract failed; expected ${SC_LINT_VERSION}" >&2 + echo "sc-lint setup: version contract failed; expected ${SC_LINT_VERSION}, got ${actual:-}" >&2 + echo "${version_json}" >&2 exit 1 } - name: Verify sc-lint version contract (Windows) @@ -106,9 +109,15 @@ runs: SC_LINT_VERSION: ${{ inputs.version }} SC_LINT_REPOSITORY: ${{ inputs.repository }} run: | - $value = sc-lint version --json | ConvertFrom-Json - if (-not $value.ok -or $value.data.crate_version -ne $env:SC_LINT_VERSION) { - throw "sc-lint setup: expected version $env:SC_LINT_VERSION, got $($value.data.crate_version)" + $raw = sc-lint version --json + $value = $raw | ConvertFrom-Json + $actual = if ($null -ne $value.data.version -and "$($value.data.version)" -ne '') { + $value.data.version + } else { + $value.data.crate_version + } + if (-not $value.ok -or $actual -ne $env:SC_LINT_VERSION) { + throw "sc-lint setup: expected version $env:SC_LINT_VERSION, got $actual. $raw" } - name: Materialize pinned sc-lint Python utilities (Unix) if: runner.os != 'Windows' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a094f52..3932824c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,8 @@ jobs: - name: cargo clippy run: cargo clippy --workspace -- -D warnings - - name: Install sc-lint from crates.io - run: cargo install sc-lint --version 0.4.0 --locked + - name: Set up sc-lint (published release bundle) + uses: ./.github/actions/setup-sc-lint - name: sc-lint check run: sc-lint check native --config .sc-lint.toml @@ -290,9 +290,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Enforce boundaries/*.toml against Cargo.toml + + - name: Set up sc-lint (published release bundle) + uses: ./.github/actions/setup-sc-lint + + - name: sc-lint boundary inventory + run: sc-lint lint sc-boundary --config .sc-lint.toml + + - name: Enforce io_forbidden grep policy run: python3 scripts/check-boundaries.py + - name: Verify ui/ matches crates/wyvern/ui/ run: bash scripts/check-ui-sync.sh + - name: Verify share/ and scripts/ext match crates/wyvern/ run: bash scripts/check-share-sync.sh diff --git a/.sc-lint.toml b/.sc-lint.toml index 53dd7ebd..3e8c4b07 100644 --- a/.sc-lint.toml +++ b/.sc-lint.toml @@ -1,6 +1,10 @@ -# sc-lint workspace config (Phase A — a.7) -# Install: cargo install sc-lint --version 0.4.0 --locked +# sc-lint workspace config (Phase A — a.7, upgraded 0.5.0) +# Local install: cargo install sc-lint --version 0.5.0 --locked +# CI install: .github/actions/setup-sc-lint (GitHub release bundle) # Canonical check: sc-lint check native --config .sc-lint.toml +[tool.sc-lint] +minimum_version = "0.5.0" + [workspace] root = "." diff --git a/boundaries/planning.toml b/boundaries/planning.toml new file mode 100644 index 00000000..683d5aa0 --- /dev/null +++ b/boundaries/planning.toml @@ -0,0 +1,3 @@ +[planning] +# Wyvern does not use inventory-parity sprint escalation yet; set a stable sentinel. +current_sprint = "J.5" diff --git a/boundaries/wyvern/cli.toml b/boundaries/wyvern-cli/cli.toml similarity index 53% rename from boundaries/wyvern/cli.toml rename to boundaries/wyvern-cli/cli.toml index 06a0ed95..b57eed22 100644 --- a/boundaries/wyvern/cli.toml +++ b/boundaries/wyvern-cli/cli.toml @@ -1,9 +1,29 @@ boundary_id = "BOUNDARY-WyvernCli" owner_package = "wyvern-cli" +owner_crate_path = "wyvern_cli" name = "WyvernCli" +[public] +facade = "run_from_loaded" + +[implementation] +type = "PipelineError" +module = "wyvern_cli" +visibility = "public" +constructor = "none" + +[composition] +roots = [ + "CliArgs", + "PipelineError", + "run_from_loaded", + "spawn_embedded_viewer", + "run_wizard_command", + "ExtensionRegistry", + "load_command_input", +] + [dependencies] -# wyvern-viewer: optional dep for dev binary-path helpers; embedded spawn uses subprocess (no wry in CLI) allowed_dependents = [] allowed_dependencies = [ "wyvern-schema", @@ -22,15 +42,24 @@ allowed_dependencies = [ "libc", ] forbidden_edges = [ + { from = "wyvern-cli", to = "wyvern-mcp" }, { from = "wyvern-cli", to = "wry" }, { from = "wyvern-cli", to = "winit" }, { from = "wyvern-cli", to = "rfd" }, ] -[ownership] -io_owns = ["stdin_reading", "stdout_writing", "stderr_writing", "arg_parsing", "host_options", "viewer_flag", "embedded_viewer_spawn", "viewer_show_hide", "workflow_script_spawn", "wizard_chain_loop"] -io_forbidden = ["http_server", "webview_creation", "static_file_serve", "dialog_content_html"] +[references] +scope = "outside_owner_crate" +forbidden = [] + +[testing] +allowed_test_double_paths = [] +forbidden_test_bypasses = [] [enforcement] lint_rules = ["LINT-BOUNDARY-CLI-NO-WINDOW-DIRECT"] +review_gates = ["no_direct_webview_deps"] + +[status] +state = "concrete_landed" diff --git a/boundaries/wyvern-host/host.toml b/boundaries/wyvern-host/host.toml index 1d4ca20a..7606e80b 100644 --- a/boundaries/wyvern-host/host.toml +++ b/boundaries/wyvern-host/host.toml @@ -1,19 +1,65 @@ boundary_id = "BOUNDARY-WyvernHost" owner_package = "wyvern-host" +owner_crate_path = "wyvern_host" name = "WyvernHost" +[public] +facade = "run" + +[implementation] +type = "HostError" +module = "wyvern_host" +visibility = "public" +constructor = "none" + +[composition] +roots = [ + "run", + "begin", + "DialogHandle", + "HostOptions", + "HostError", + "ViewerMode", + "BrowserRegistryEntry", +] + [dependencies] -allowed_dependencies = ["wyvern-schema", "serde", "serde_json", "axum", "tokio", "tower", "tower-http", "tracing", "rfd", "webbrowser", "dirs", "pulldown-cmark", "ammonia", "wyvern-wizard"] +allowed_dependents = ["wyvern-cli", "wyvern-mcp"] +allowed_dependencies = [ + "wyvern-schema", + "serde", + "serde_json", + "axum", + "tokio", + "tower", + "tower-http", + "tracing", + "rfd", + "webbrowser", + "dirs", + "pulldown-cmark", + "ammonia", + "wyvern-wizard", +] forbidden_edges = [ + { from = "wyvern-host", to = "wyvern-cli" }, { from = "wyvern-host", to = "wyvern-mcp" }, { from = "wyvern-host", to = "wry" }, { from = "wyvern-host", to = "winit" }, ] -[ownership] -io_owns = ["tcp_bind", "http_server", "static_file_serve", "dialog_session", "wizard_session", "wizard_routes", "report_routes", "report_session", "result_channel", "native_file_picker", "browser_registry", "system_browser_launch", "dialog_content_html", "dialog_preview_html"] -io_forbidden = ["stdin_reading", "stdout_writing", "arg_parsing", "webview_creation", "inline_html_embed", "html_template_generation", "mcp_protocol", "embedded_viewer_spawn", "wizard_history_internals", "wizard_domain_logic", "workflow_script_spawn", "wizard_chain_loop"] +[references] +scope = "outside_owner_crate" +forbidden = [] + +[testing] +allowed_test_double_paths = [] +forbidden_test_bypasses = [] [enforcement] lint_rules = ["LINT-BOUNDARY-HOST-NO-CLI", "LINT-BOUNDARY-HOST-NO-WEBVIEW"] +review_gates = ["http_host_only"] + +[status] +state = "concrete_landed" diff --git a/boundaries/wyvern-mcp/mcp.toml b/boundaries/wyvern-mcp/mcp.toml index 85606413..6ecc04f1 100644 --- a/boundaries/wyvern-mcp/mcp.toml +++ b/boundaries/wyvern-mcp/mcp.toml @@ -1,17 +1,39 @@ boundary_id = "BOUNDARY-WyvernMcp" owner_package = "wyvern-mcp" +owner_crate_path = "wyvern_mcp" name = "WyvernMcp" +[public] +facade = "wyvern_mcp" + +[implementation] +visibility = "trait_only" + +[composition] +roots = [] + [dependencies] -allowed_dependencies = ["wyvern-schema", "wyvern-host", "tokio"] +allowed_dependents = [] +allowed_dependencies = ["wyvern-schema"] forbidden_edges = [ + { from = "wyvern-mcp", to = "wyvern-cli" }, { from = "wyvern-mcp", to = "wyvern-wizard" }, + { from = "wyvern-mcp", to = "wry" }, + { from = "wyvern-mcp", to = "winit" }, ] -[ownership] -io_owns = ["mcp_stdio_transport", "tool_registration", "persistent_host_lifecycle"] -io_forbidden = ["arg_parsing", "stdin_readline_loop", "direct_wizard_navigation", "http_server_impl"] +[references] +scope = "outside_owner_crate" +forbidden = [] + +[testing] +allowed_test_double_paths = [] +forbidden_test_bypasses = [] [enforcement] lint_rules = ["LINT-BOUNDARY-MCP-NO-CLI", "LINT-BOUNDARY-MCP-WINDOW-VIA-API-ONLY"] +review_gates = ["phase_e_stub"] + +[status] +state = "planned" diff --git a/boundaries/wyvern-schema/schema.toml b/boundaries/wyvern-schema/schema.toml index dd189842..e89be704 100644 --- a/boundaries/wyvern-schema/schema.toml +++ b/boundaries/wyvern-schema/schema.toml @@ -1,10 +1,34 @@ boundary_id = "BOUNDARY-WyvernSchema" owner_package = "wyvern-schema" +owner_crate_path = "wyvern_schema" name = "WyvernSchema" +[public] +facade = "Command" + +[implementation] +type = "Command" +module = "wyvern_schema" +visibility = "public" +constructor = "none" + +[composition] +roots = [ + "ButtonLabel", + "ChromeStatus", + "ChromeTitle", + "Command", + "ValidationError", + "ErrorCode", + "CommandResult", + "validate", +] + [dependencies] +allowed_dependents = ["wyvern-cli", "wyvern-host", "wyvern-wizard", "wyvern-mcp"] allowed_dependencies = ["serde", "serde_json", "strsim"] forbidden_edges = [ + { from = "wyvern-schema", to = "wyvern-cli" }, { from = "wyvern-schema", to = "wyvern-wizard" }, { from = "wyvern-schema", to = "wyvern-mcp" }, @@ -13,9 +37,17 @@ forbidden_edges = [ { from = "wyvern-schema", to = "rfd" }, ] -[ownership] -io_owns = ["type_definitions", "validation_logic", "error_message_formatting"] -io_forbidden = ["file_io", "network_io", "window_creation", "webview", "async_runtime"] +[references] +scope = "outside_owner_crate" +forbidden = [] + +[testing] +allowed_test_double_paths = [] +forbidden_test_bypasses = [] [enforcement] lint_rules = ["LINT-BOUNDARY-SCHEMA-PURE-LOGIC"] +review_gates = ["pure_logic_only"] + +[status] +state = "concrete_landed" diff --git a/boundaries/wyvern-viewer/viewer.toml b/boundaries/wyvern-viewer/viewer.toml index 7d4016fa..9eaa1bd8 100644 --- a/boundaries/wyvern-viewer/viewer.toml +++ b/boundaries/wyvern-viewer/viewer.toml @@ -1,20 +1,48 @@ boundary_id = "BOUNDARY-WyvernViewer" owner_package = "wyvern-viewer" +owner_crate_path = "wyvern_viewer" name = "WyvernViewer" -status = "active" + +[public] +facade = "DismissError" + +[implementation] +type = "DismissError" +module = "wyvern_viewer" +visibility = "public" +constructor = "none" + +[composition] +roots = [ + "DismissError", + "ViewportBounds", + "resolve_bootstrap_size", + "post_dismissed", + "is_wizard_dialog_url", +] [dependencies] +allowed_dependents = ["wyvern-cli"] allowed_dependencies = ["wry", "winit", "url", "tracing", "serde", "serde_json", "gtk"] forbidden_edges = [ - { from = "wyvern-viewer", to = "wyvern" }, + + { from = "wyvern-viewer", to = "wyvern-cli" }, { from = "wyvern-viewer", to = "wyvern-mcp" }, { from = "wyvern-viewer", to = "wyvern-host" }, { from = "wyvern-viewer", to = "wyvern-schema" }, ] -[ownership] -io_owns = ["webview_open_url", "webview_show_hide", "viewer_lifecycle_stdin", "chrome_presentation_ipc"] -io_forbidden = ["http_server", "stdout_writing", "inline_html"] +[references] +scope = "outside_owner_crate" +forbidden = [] + +[testing] +allowed_test_double_paths = [] +forbidden_test_bypasses = [] [enforcement] lint_rules = ["LINT-BOUNDARY-VIEWER-URL-ONLY"] +review_gates = ["url_only_webview"] + +[status] +state = "concrete_landed" diff --git a/boundaries/wyvern-wizard/wizard.toml b/boundaries/wyvern-wizard/wizard.toml index aed5ee8f..47db94bb 100644 --- a/boundaries/wyvern-wizard/wizard.toml +++ b/boundaries/wyvern-wizard/wizard.toml @@ -1,12 +1,36 @@ boundary_id = "BOUNDARY-WyvernWizard" owner_package = "wyvern-wizard" +owner_crate_path = "wyvern_wizard" name = "WyvernWizard" +[public] +facade = "WizardSession" + +[implementation] +type = "WizardSession" +module = "wyvern_wizard" +visibility = "public" +constructor = "none" + +[composition] +roots = [ + "WizardSession", + "WizardSnapshot", + "WizardError", + "NavigateOutcome", + "lint", + "dataflow", + "LintFinding", + "PageInfo", + "DataflowSpec", +] + [dependencies] +allowed_dependents = ["wyvern-host", "wyvern-cli"] allowed_dependencies = ["wyvern-schema", "serde_json"] forbidden_edges = [ - { from = "wyvern-wizard", to = "wyvern" }, - { from = "wyvern-wizard", to = "wyvern-window" }, + + { from = "wyvern-wizard", to = "wyvern-cli" }, { from = "wyvern-wizard", to = "wyvern-host" }, { from = "wyvern-wizard", to = "wyvern-mcp" }, { from = "wyvern-wizard", to = "wry" }, @@ -19,29 +43,17 @@ forbidden_edges = [ { from = "wyvern-wizard", to = "reqwest" }, ] -[ownership] -io_owns = ["wizard_session", "history_cursor", "stack_snapshot", "navigation"] -io_forbidden = ["file_io", "network_io", "window_creation", "webview", "ipc", "async_runtime", "http_routes", "browser_history_internals"] - -[public_api] -# Host and tests: session API. wyvern-cli (Phase G g.9/g.14): static lint/dataflow only. -allowed_modules = [ - "wyvern_wizard::WizardSession", - "wyvern_wizard::WizardSnapshot", - "wyvern_wizard::WizardError", - "wyvern_wizard::NavigateOutcome", - "wyvern_wizard::lint", - "wyvern_wizard::dataflow", - "wyvern_wizard::graph", - "wyvern_wizard::LintFinding", - "wyvern_wizard::PageInfo", - "wyvern_wizard::PageRole", - "wyvern_wizard::GraphPage", - "wyvern_wizard::WizardPageGraph", - "wyvern_wizard::DataflowSpec", - "wyvern_wizard::DataflowLintInput", -] -permitted_consumers = ["wyvern-host", "wyvern-cli"] +[references] +scope = "outside_owner_crate" +forbidden = [] + +[testing] +allowed_test_double_paths = [] +forbidden_test_bypasses = [] [enforcement] lint_rules = ["LINT-BOUNDARY-WIZARD-PURE-LOGIC"] +review_gates = ["pure_logic_only"] + +[status] +state = "concrete_landed" diff --git a/crates/wyvern-host/Cargo.toml b/crates/wyvern-host/Cargo.toml index ee793e07..6a638e90 100644 --- a/crates/wyvern-host/Cargo.toml +++ b/crates/wyvern-host/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true +authors.workspace = true description = "Wyvern HTTP dialog host — bind, serve packaged UI, await POST /api/result" keywords = ["wyvern", "dialog", "webview", "http"] categories = ["command-line-utilities", "web-programming"] diff --git a/crates/wyvern-mcp/Cargo.toml b/crates/wyvern-mcp/Cargo.toml index c03d3c40..0d37d7e7 100644 --- a/crates/wyvern-mcp/Cargo.toml +++ b/crates/wyvern-mcp/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true +authors.workspace = true description = "Wyvern MCP server — persistent native webview via Model Context Protocol" publish = false diff --git a/crates/wyvern-schema/Cargo.toml b/crates/wyvern-schema/Cargo.toml index 2b3608f0..c3e03129 100644 --- a/crates/wyvern-schema/Cargo.toml +++ b/crates/wyvern-schema/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true +authors.workspace = true description = "Wyvern JSON types, validation, and error messages" keywords = ["wyvern", "dialog", "webview", "cli", "json"] categories = ["command-line-utilities"] diff --git a/crates/wyvern-viewer/Cargo.toml b/crates/wyvern-viewer/Cargo.toml index 005d975a..943cdc4c 100644 --- a/crates/wyvern-viewer/Cargo.toml +++ b/crates/wyvern-viewer/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true +authors.workspace = true description = "Wyvern embedded URL viewer — wry window that loads a dialog URL" keywords = ["wyvern", "webview", "viewer", "wry"] categories = ["command-line-utilities", "gui"] diff --git a/crates/wyvern-wizard/Cargo.toml b/crates/wyvern-wizard/Cargo.toml index e405d118..507f808d 100644 --- a/crates/wyvern-wizard/Cargo.toml +++ b/crates/wyvern-wizard/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true +authors.workspace = true description = "Wyvern wizard navigation state machine (browser-history cursor)" keywords = ["wyvern", "dialog", "wizard", "state-machine"] categories = ["command-line-utilities"] diff --git a/crates/wyvern/Cargo.toml b/crates/wyvern/Cargo.toml index 7a8a8b22..7bae3cef 100644 --- a/crates/wyvern/Cargo.toml +++ b/crates/wyvern/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true +authors.workspace = true description = "What You View, Engine Renders Natively — HTTP dialog CLI" keywords = ["wyvern", "dialog", "webview", "cli", "json"] categories = ["command-line-utilities", "gui"] diff --git a/docs/linting.md b/docs/linting.md index be874900..f2ea5233 100644 --- a/docs/linting.md +++ b/docs/linting.md @@ -1,28 +1,35 @@ # Linting -Wyvern uses [`sc-lint`](https://crates.io/crates/sc-lint) from crates.io for -workspace policy checks in local development and CI. +Wyvern uses [`sc-lint`](https://crates.io/crates/sc-lint) **0.5.0** for workspace +policy checks in local development and CI. CI installs the verified GitHub release +bundle (`sc-lint`, `sc-lint-boundary`, `sc-lint-portability`, `sc-lint-runtime`) +via [`.github/actions/setup-sc-lint`](../.github/actions/setup-sc-lint). -Boundary TOML under `boundaries/` is inventory for later phases; Phase A does -not enforce `sc-lint-boundary` in CI (see Phase B planning). +Boundary dependency allowlists and forbidden edges are enforced by +`sc-lint lint sc-boundary` against ADR-004 records under `boundaries//`. +Wyvern-specific `io_forbidden` grep policy lives in +[`scripts/io-forbidden.toml`](../scripts/io-forbidden.toml) and is checked by +`scripts/check-boundaries.py`. ## Install -Pin to the 0.4 line (exact release `0.4.0`): +Pin to **0.5.0** (local development): ```bash -cargo install sc-lint --version 0.4.0 --locked +cargo install sc-lint --version 0.5.0 --locked ``` Ensure `~/.cargo/bin` is on `PATH` so the crates.io binary is used (Homebrew -formulas may still ship an older `sc-lint`). +formulas may ship an older `sc-lint`). ## Config -Repo-root [`.sc-lint.toml`](../.sc-lint.toml) scopes the tool to this -workspace: +Repo-root [`.sc-lint.toml`](../.sc-lint.toml) declares the consumer contract: ```toml +[tool.sc-lint] +minimum_version = "0.5.0" + [workspace] root = "." ``` @@ -36,12 +43,22 @@ file. sc-lint check native --config .sc-lint.toml ``` -`check` requires a target (`native` or `xwin`). Phase A CI uses `native`, which +`check` requires a target (`native` or `xwin`). CI uses `native`, which runs `cargo check --workspace` and must pass with zero warnings/failures. Always pass `--test-threads=1` for workspace tests on macOS (winit/objc races when multiple webview children spawn). CI already enforces this; local runs must match. +## Published analyzers (0.5.0) + +| Backend | CLI target | Wyvern CI | +|---------|------------|-----------| +| Compile gate | `sc-lint check native` | **Yes** — all matrix legs | +| Boundary graph | `sc-lint lint sc-boundary` | **Yes** — boundaries CI job | +| Portability | `sc-lint lint sc-portability` | Not run | +| Runtime liveness | `sc-lint lint sc-runtime` | Setup smoke test only | +| Full consumer CI | `sc-lint ci` | Not run (requires `sc-lint init --just`) | + ## Panic policy Production paths must not panic. Panics are forbidden in non-test code in @@ -50,14 +67,14 @@ Production paths must not panic. Panics are forbidden in non-test code in `panic!`. **Enforcement is Clippy crate-root denies — not a `.sc-lint.toml` key.** -`sc-lint` 0.4.x has no panic/unwrap policy knobs. +`sc-lint` 0.5.x has no panic/unwrap policy knobs. | Surface | Detects production `unwrap`/`expect`/`panic!`? | Wyvern CI | |---------|-----------------------------------------------|-----------| | `sc-lint check native` | **No** — wraps `cargo check --workspace` | Yes | | `sc-lint clippy native` | **Indirect** — wraps `cargo clippy -D warnings`; honors crate `#![deny(...)]` | No (direct `cargo clippy` instead) | -| `sc-lint lint sc-boundary` | **No** — dependency/ownership graph | Stub only | -| `sc-lint lint sc-runtime` | **No** — condvar liveness only | Not run | +| `sc-lint lint sc-boundary` | **No** — dependency/ownership graph | Yes | +| `sc-lint lint sc-runtime` | **No** — condvar liveness only | Setup smoke only | Authoritative regression gate: @@ -75,5 +92,8 @@ sc-lint clippy native --config .sc-lint.toml ## CI Every matrix leg (`ubuntu-latest`, `macos-latest`, `windows-latest`) installs -`sc-lint` 0.4.0 from crates.io and runs the canonical command above. See -[`.github/workflows/ci.yml`](../.github/workflows/ci.yml). +sc-lint **0.5.0** from the GitHub release bundle and runs the canonical command +above. See [`.github/workflows/ci.yml`](../.github/workflows/ci.yml). + +The **Boundary lint** job runs `sc-lint lint sc-boundary`, `scripts/check-boundaries.py` +(io_forbidden greps), and ui/share sync checks. diff --git a/scripts/check-boundaries.py b/scripts/check-boundaries.py index bc600ceb..6c31382c 100755 --- a/scripts/check-boundaries.py +++ b/scripts/check-boundaries.py @@ -1,25 +1,9 @@ #!/usr/bin/env python3 -"""Enforce boundaries/*.toml dependency allow/forbid lists against Cargo.toml. - -Validates each boundary that names an existing owner package: - - every direct Cargo dependency must appear in allowed_dependencies - - no direct Cargo dependency may appear in forbidden_dependencies or - forbidden_edges for the owner package - - io_forbidden tokens receive minimal source-grep enforcement (c.15+) - -Ownership note: - - io_owns remains an advisory ownership declaration (documents which crate - is responsible for a concern). Mechanical ownership of every io_owns - token is not enforced here. - - io_forbidden is enforced with lightweight path+content greps scoped to - the owner crate sources so regressions like host spawning wyvern-viewer - or viewer gaining an HTTP server are caught in the boundaries CI job. - -Aspirational lint_rules: - - [enforcement].lint_rules entries (e.g. LINT-BOUNDARY-*) are aspirational - metadata until those rules are implemented in sc-lint / .sc-lint.toml. - - This script does not enforce lint_rules; the current mechanical gate for - ownership forbids is the io_forbidden greps above (plus Cargo dep checks). +"""Enforce Wyvern io_forbidden grep policy from scripts/io-forbidden.toml. + +ADR-004 boundary inventory and package dependency policy are enforced by +`sc-lint lint sc-boundary`. This script covers Wyvern-specific ownership greps +that are not yet modeled in sc-lint-boundary. Exits 0 on success, 1 on violation or parse error. """ @@ -32,12 +16,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -BOUNDARIES = ROOT / "boundaries" +IO_POLICY = ROOT / "scripts" / "io-forbidden.toml" CRATES = ROOT / "crates" -# Minimal grep patterns for io_forbidden tokens used by active c.15 boundaries. -# Patterns are applied only to the owner package that declares the forbid. -# Doc comments / line comments are skipped before matching. IO_FORBIDDEN_GREPS: dict[str, list[re.Pattern[str]]] = { "http_server": [ re.compile(r"\baxum\b"), @@ -52,8 +33,8 @@ "embedded_viewer_spawn": [ re.compile(r"\bspawn_embedded_viewer\b"), re.compile(r'Command::new\([^;\n]*wyvern-viewer'), - re.compile(r'CARGO_BIN_EXE_wyvern-viewer'), - re.compile(r'WYVERN_VIEWER_BIN'), + re.compile(r"CARGO_BIN_EXE_wyvern-viewer"), + re.compile(r"WYVERN_VIEWER_BIN"), ], "webview_creation": [ re.compile(r"\bWebViewBuilder\b"), @@ -89,13 +70,11 @@ def code_lines_without_comments(text: str) -> str: - """Strip // and /// / //! line comments for lightweight matching.""" out: list[str] = [] for line in text.splitlines(): stripped = line.lstrip() if stripped.startswith("//"): continue - # Drop trailing // comments (naive; good enough for boundary greps). if "//" in line: line = line.split("//", 1)[0] out.append(line) @@ -106,7 +85,6 @@ def package_dir(owner: str) -> Path | None: candidate = CRATES / owner if (candidate / "Cargo.toml").is_file(): return candidate - # owner_package is the Cargo package name (e.g. wyvern-cli → crates/wyvern). for crate_dir in sorted(CRATES.iterdir()): manifest = crate_dir / "Cargo.toml" if not manifest.is_file(): @@ -117,19 +95,6 @@ def package_dir(owner: str) -> Path | None: return None -def cargo_dep_names(manifest: Path) -> set[str]: - data = tomllib.loads(manifest.read_text(encoding="utf-8")) - names: set[str] = set() - for section in ("dependencies", "build-dependencies"): - for name in data.get(section, {}): - names.add(name) - return names - - -def load_boundary(path: Path) -> dict: - return tomllib.loads(path.read_text(encoding="utf-8")) - - def iter_rs_sources(pkg: Path) -> list[Path]: src = pkg / "src" if not src.is_dir(): @@ -137,10 +102,8 @@ def iter_rs_sources(pkg: Path) -> list[Path]: return sorted(src.rglob("*.rs")) -def check_io_forbidden(owner: str, boundary_path: Path, data: dict) -> list[str]: +def check_io_forbidden(owner: str, forbidden: list[str]) -> list[str]: errors: list[str] = [] - ownership = data.get("ownership") or {} - forbidden = list(ownership.get("io_forbidden") or []) if not forbidden: return errors @@ -155,7 +118,6 @@ def check_io_forbidden(owner: str, boundary_path: Path, data: dict) -> list[str] for token in forbidden: patterns = IO_FORBIDDEN_GREPS.get(token) if not patterns: - # Unknown token: advisory only (documented in module docstring). continue for src in sources: text = code_lines_without_comments(src.read_text(encoding="utf-8")) @@ -163,8 +125,7 @@ def check_io_forbidden(owner: str, boundary_path: Path, data: dict) -> list[str] if pat.search(text): rel = src.relative_to(ROOT) errors.append( - f"{owner}: io_forbidden '{token}' matched {pat.pattern!r} in " - f"{rel} ({boundary_path.relative_to(ROOT)})" + f"{owner}: io_forbidden '{token}' matched {pat.pattern!r} in {rel}" ) break else: @@ -173,80 +134,29 @@ def check_io_forbidden(owner: str, boundary_path: Path, data: dict) -> list[str] return errors -def check_one(boundary_path: Path) -> list[str]: - errors: list[str] = [] - data = load_boundary(boundary_path) - owner = data.get("owner_package") - if not owner: - errors.append(f"{boundary_path}: missing owner_package") - return errors - - pkg = package_dir(owner) - if pkg is None: - # Planned packages are inventory-only until present. - return errors - - deps = data.get("dependencies") or {} - allowed = set(deps.get("allowed_dependencies") or []) - forbidden = set(deps.get("forbidden_dependencies") or []) - for edge in deps.get("forbidden_edges") or []: - if isinstance(edge, dict): - src = str(edge.get("from", "")).strip() - dst = str(edge.get("to", "")).strip() - elif isinstance(edge, str) and "->" in edge: - src, dst = (part.strip() for part in edge.split("->", 1)) - else: - continue - if src == owner: - forbidden.add(dst) - if allowed or forbidden: - cargo_deps = cargo_dep_names(pkg / "Cargo.toml") - - if allowed: - unknown = sorted(cargo_deps - allowed) - if unknown: - errors.append( - f"{owner}: Cargo.toml deps not in allowed_dependencies " - f"({boundary_path.relative_to(ROOT)}): {', '.join(unknown)}" - ) - - banned = sorted(cargo_deps & forbidden) - if banned: - errors.append( - f"{owner}: Cargo.toml deps in forbidden_dependencies " - f"({boundary_path.relative_to(ROOT)}): {', '.join(banned)}" - ) - - errors.extend(check_io_forbidden(owner, boundary_path, data)) - return errors - - def main() -> int: - if not BOUNDARIES.is_dir(): - print(f"error: boundaries directory missing: {BOUNDARIES}", file=sys.stderr) - return 1 - - tomls = sorted(BOUNDARIES.glob("*/*.toml")) - if not tomls: - print("error: no boundary TOML files found", file=sys.stderr) + if not IO_POLICY.is_file(): + print(f"error: missing io policy file: {IO_POLICY}", file=sys.stderr) return 1 + policy = tomllib.loads(IO_POLICY.read_text(encoding="utf-8")) errors: list[str] = [] checked = 0 - for path in tomls: - data = load_boundary(path) - owner = data.get("owner_package") - if owner and package_dir(owner) is not None: + for owner, section in policy.items(): + if not isinstance(section, dict): + continue + forbidden = list(section.get("io_forbidden") or []) + if package_dir(owner) is not None: checked += 1 - errors.extend(check_one(path)) + errors.extend(check_io_forbidden(owner, forbidden)) if errors: - print("boundary check FAILED:", file=sys.stderr) + print("io-forbidden check FAILED:", file=sys.stderr) for err in errors: print(f" - {err}", file=sys.stderr) return 1 - print(f"boundary check OK ({checked} package(s), {len(tomls)} boundary file(s))") + print(f"io-forbidden check OK ({checked} package(s))") return 0 diff --git a/scripts/io-forbidden.toml b/scripts/io-forbidden.toml new file mode 100644 index 00000000..41da7dba --- /dev/null +++ b/scripts/io-forbidden.toml @@ -0,0 +1,39 @@ +# Wyvern-specific io_forbidden grep policy (not part of sc-lint ADR-004 schema). +# Enforced by scripts/check-boundaries.py alongside sc-lint lint sc-boundary. + +[wyvern-cli] +io_forbidden = ["http_server", "webview_creation", "static_file_serve", "dialog_content_html"] + +[wyvern-host] +io_forbidden = [ + "stdin_reading", + "stdout_writing", + "arg_parsing", + "webview_creation", + "inline_html_embed", + "html_template_generation", + "mcp_protocol", + "embedded_viewer_spawn", + "wizard_history_internals", + "wizard_domain_logic", + "workflow_script_spawn", + "wizard_chain_loop", +] + +[wyvern-wizard] +io_forbidden = [ + "file_io", + "network_io", + "window_creation", + "webview", + "ipc", + "async_runtime", + "http_routes", + "browser_history_internals", +] + +[wyvern-viewer] +io_forbidden = ["http_server", "stdout_writing", "inline_html"] + +[wyvern-mcp] +io_forbidden = ["arg_parsing", "stdin_readline_loop", "direct_wizard_navigation", "http_server_impl"]