From 063285cc4a75082a348dfd515f2db30dfe1f44de Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Tue, 1 Sep 2026 12:40:28 -0700 Subject: [PATCH 1/4] Upgrade wyvern to sc-lint 0.5.0 published tools and ADR-004 boundaries. Install the verified GitHub release bundle in CI, migrate boundary inventory to the structured sc-lint 0.5.0 schema, and enforce package edges via sc-lint lint sc-boundary with io_forbidden greps kept in a separate policy file. Co-authored-by: Cursor --- .github/actions/setup-sc-lint/action.yml | 172 +++++++++++++++++++++++ .github/workflows/ci.yml | 15 +- .sc-lint.toml | 8 +- boundaries/planning.toml | 3 + boundaries/wyvern-cli/cli.toml | 65 +++++++++ boundaries/wyvern-host/host.toml | 62 +++++++- boundaries/wyvern-mcp/mcp.toml | 36 ++++- boundaries/wyvern-schema/schema.toml | 47 ++++++- boundaries/wyvern-viewer/viewer.toml | 44 +++++- boundaries/wyvern-wizard/wizard.toml | 73 ++++++---- boundaries/wyvern/cli.toml | 31 ---- crates/wyvern-host/Cargo.toml | 1 + crates/wyvern-mcp/Cargo.toml | 1 + crates/wyvern-schema/Cargo.toml | 1 + crates/wyvern-viewer/Cargo.toml | 1 + crates/wyvern-wizard/Cargo.toml | 1 + crates/wyvern/Cargo.toml | 1 + docs/linting.md | 50 +++++-- scripts/check-boundaries.py | 121 +++------------- scripts/io-forbidden.toml | 39 +++++ 20 files changed, 575 insertions(+), 197 deletions(-) create mode 100644 .github/actions/setup-sc-lint/action.yml create mode 100644 boundaries/planning.toml create mode 100644 boundaries/wyvern-cli/cli.toml delete mode 100644 boundaries/wyvern/cli.toml create mode 100644 scripts/io-forbidden.toml diff --git a/.github/actions/setup-sc-lint/action.yml b/.github/actions/setup-sc-lint/action.yml new file mode 100644 index 00000000..f5aec186 --- /dev/null +++ b/.github/actions/setup-sc-lint/action.yml @@ -0,0 +1,172 @@ +name: Setup sc-lint +description: >- + Install and verify the pinned sc-lint release and sibling analyzers. The + default repository and version are the deliberate shared ecosystem pin for + every kit consumer; the source archive's .just/*.py utilities are copied + into the workspace's .just/ directory (the layout sc-lint's Python helpers + require). +inputs: + version: + description: Released sc-lint version to install (deliberate ecosystem pin) + required: false + default: "0.5.0" + repository: + description: GitHub repository slug hosting sc-lint releases + required: false + default: "randlee/sc-lint" +runs: + using: composite + steps: + - name: Install sc-lint release (Unix) + if: runner.os != 'Windows' + shell: bash + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + set -euo pipefail + case "${RUNNER_OS}:${RUNNER_ARCH}" in + Linux:X64) target="x86_64-unknown-linux-gnu" ;; + macOS:X64) target="x86_64-apple-darwin" ;; + macOS:ARM64) target="aarch64-apple-darwin" ;; + *) + echo "sc-lint setup: unsupported runner ${RUNNER_OS}/${RUNNER_ARCH}" >&2 + exit 1 + ;; + esac + archive="sc-lint_${SC_LINT_VERSION}_${target}.tar.gz" + url="https://github.com/${SC_LINT_REPOSITORY}/releases/download/v${SC_LINT_VERSION}/${archive}" + install_dir="${RUNNER_TEMP}/sc-lint-${SC_LINT_VERSION}/${target}" + mkdir -p "${install_dir}" + curl --fail --location --retry 4 --retry-all-errors --silent --show-error \ + --output "${RUNNER_TEMP}/${archive}" "${url}" || { + echo "sc-lint setup: could not download ${url}; verify that release v${SC_LINT_VERSION} publishes this runner asset" >&2 + exit 1 + } + tar -xzf "${RUNNER_TEMP}/${archive}" -C "${install_dir}" + test -x "${install_dir}/sc-lint" || { + echo "sc-lint setup: release archive is missing executable sc-lint" >&2 + exit 1 + } + for backend in sc-lint-boundary sc-lint-portability sc-lint-runtime; do + test -x "${install_dir}/${backend}" || { + echo "sc-lint setup: release archive is missing sibling backend ${backend}" >&2 + exit 1 + } + done + echo "${install_dir}" >> "${GITHUB_PATH}" + - name: Install sc-lint release (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:RUNNER_ARCH -ne 'X64') { + throw "sc-lint setup: unsupported Windows architecture $env:RUNNER_ARCH" + } + $target = 'x86_64-pc-windows-msvc' + $archive = "sc-lint_$env:SC_LINT_VERSION`_$target.zip" + $url = "https://github.com/$env:SC_LINT_REPOSITORY/releases/download/v$env:SC_LINT_VERSION/$archive" + $installDir = Join-Path $env:RUNNER_TEMP "sc-lint-$env:SC_LINT_VERSION\$target" + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $archivePath = Join-Path $env:RUNNER_TEMP $archive + try { + Invoke-WebRequest -Uri $url -OutFile $archivePath + } catch { + throw "sc-lint setup: could not download $url; verify that release v$env:SC_LINT_VERSION publishes this runner asset. $($_.Exception.Message)" + } + Expand-Archive -Path $archivePath -DestinationPath $installDir -Force + foreach ($binary in @('sc-lint.exe', 'sc-lint-boundary.exe', 'sc-lint-portability.exe', 'sc-lint-runtime.exe')) { + if (-not (Test-Path (Join-Path $installDir $binary))) { + throw "sc-lint setup: release archive is missing sibling backend $binary" + } + } + Add-Content -Path $env:GITHUB_PATH -Value $installDir + - name: Verify sc-lint version contract + if: runner.os != 'Windows' + shell: bash + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + set -euo pipefail + version_json="$(sc-lint version --json)" + jq -e --arg expected "${SC_LINT_VERSION}" \ + '.ok == true and .data.crate_version == $expected' \ + <<<"${version_json}" >/dev/null || { + echo "sc-lint setup: version contract failed; expected ${SC_LINT_VERSION}" >&2 + exit 1 + } + - name: Verify sc-lint version contract (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + 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)" + } + - name: Materialize pinned sc-lint Python utilities (Unix) + if: runner.os != 'Windows' + shell: bash + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/sc-lint-source-${SC_LINT_VERSION}.tar.gz" + source_root="${RUNNER_TEMP}/sc-lint-source-${SC_LINT_VERSION}" + mkdir -p "${source_root}" + curl --fail --location --retry 4 --retry-all-errors --silent --show-error \ + --output "${archive}" \ + "https://github.com/${SC_LINT_REPOSITORY}/archive/refs/tags/v${SC_LINT_VERSION}.tar.gz" + tar -xzf "${archive}" -C "${source_root}" --strip-components=1 + mkdir -p "${GITHUB_WORKSPACE}/.just" + cp "${source_root}/.just/"*.py "${GITHUB_WORKSPACE}/.just/" + printf '%s\n' "${SC_LINT_VERSION}" > "${GITHUB_WORKSPACE}/.just/.sc-lint-runtime-version" + test -f "${GITHUB_WORKSPACE}/.just/run_lint.py" + - name: Materialize pinned sc-lint Python utilities (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + $ErrorActionPreference = 'Stop' + $archive = Join-Path $env:RUNNER_TEMP "sc-lint-source-$env:SC_LINT_VERSION.zip" + $sourceRoot = Join-Path $env:RUNNER_TEMP "sc-lint-source-$env:SC_LINT_VERSION" + Invoke-WebRequest -Uri "https://github.com/$env:SC_LINT_REPOSITORY/archive/refs/tags/v$env:SC_LINT_VERSION.zip" -OutFile $archive + Expand-Archive -Path $archive -DestinationPath $sourceRoot -Force + $extracted = Get-ChildItem -Directory $sourceRoot | Select-Object -First 1 + $justRoot = Join-Path $env:GITHUB_WORKSPACE '.just' + New-Item -ItemType Directory -Force -Path $justRoot | Out-Null + Copy-Item (Join-Path $extracted.FullName '.just\*.py') $justRoot -Force + Set-Content -Path (Join-Path $justRoot '.sc-lint-runtime-version') -Value $env:SC_LINT_VERSION -NoNewline + if (-not (Test-Path (Join-Path $justRoot 'run_lint.py'))) { + throw 'sc-lint setup: source archive is missing .just/run_lint.py' + } + - name: Smoke-test repository root discovery (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + root="${GITHUB_WORKSPACE:-${PWD}}" + smoke_json="$(sc-lint --json --root "${root}" lint sc-runtime)" + jq -e '.ok == true and (.error.code // "") != "CLI.CONFIG_ERROR"' \ + <<<"${smoke_json}" >/dev/null || { + echo "sc-lint setup: root discovery failed or returned CLI.CONFIG_ERROR" >&2 + exit 1 + } + - name: Smoke-test repository root discovery (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path } + $value = sc-lint --json --root $root lint sc-runtime | ConvertFrom-Json + if (-not $value.ok -or $value.error.code -eq 'CLI.CONFIG_ERROR') { + throw "sc-lint root discovery failed: $($value | ConvertTo-Json -Compress)" + } 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/cli.toml b/boundaries/wyvern-cli/cli.toml new file mode 100644 index 00000000..b57eed22 --- /dev/null +++ b/boundaries/wyvern-cli/cli.toml @@ -0,0 +1,65 @@ +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] +allowed_dependents = [] +allowed_dependencies = [ + "wyvern-schema", + "wyvern-host", + "wyvern-wizard", + "wyvern-viewer", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", + "sc-observability", + "sc-observability-types", + "dirs", + "rust-embed", + "tempfile", + "libc", +] +forbidden_edges = [ + + { from = "wyvern-cli", to = "wyvern-mcp" }, + { from = "wyvern-cli", to = "wry" }, + { from = "wyvern-cli", to = "winit" }, + { from = "wyvern-cli", to = "rfd" }, +] + +[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 e37de443..7606e80b 100644 --- a/boundaries/wyvern-host/host.toml +++ b/boundaries/wyvern-host/host.toml @@ -1,15 +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"] -forbidden_dependencies = ["wyvern-cli", "wyvern-mcp", "wry", "winit"] -forbidden_edges = ["wyvern-host -> wyvern-cli", "wyvern-host -> wyvern-mcp"] +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 = [ -[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"] + { from = "wyvern-host", to = "wyvern-cli" }, + { from = "wyvern-host", to = "wyvern-mcp" }, + { from = "wyvern-host", to = "wry" }, + { from = "wyvern-host", to = "winit" }, +] + +[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 20f70eeb..6ecc04f1 100644 --- a/boundaries/wyvern-mcp/mcp.toml +++ b/boundaries/wyvern-mcp/mcp.toml @@ -1,15 +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"] -forbidden_dependencies = ["wyvern-cli", "wyvern-wizard"] -forbidden_edges = ["wyvern-mcp -> wyvern-cli", "wyvern-mcp -> wyvern-wizard"] +allowed_dependents = [] +allowed_dependencies = ["wyvern-schema"] +forbidden_edges = [ -[ownership] -io_owns = ["mcp_stdio_transport", "tool_registration", "persistent_host_lifecycle"] -io_forbidden = ["arg_parsing", "stdin_readline_loop", "direct_wizard_navigation", "http_server_impl"] + { from = "wyvern-mcp", to = "wyvern-cli" }, + { from = "wyvern-mcp", to = "wyvern-wizard" }, + { from = "wyvern-mcp", to = "wry" }, + { from = "wyvern-mcp", to = "winit" }, +] + +[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 cff11d56..e89be704 100644 --- a/boundaries/wyvern-schema/schema.toml +++ b/boundaries/wyvern-schema/schema.toml @@ -1,14 +1,53 @@ 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_dependencies = ["wyvern-cli", "wyvern-wizard", "wyvern-mcp", "wry", "winit", "rfd"] +forbidden_edges = [ -[ownership] -io_owns = ["type_definitions", "validation_logic", "error_message_formatting"] -io_forbidden = ["file_io", "network_io", "window_creation", "webview", "async_runtime"] + { from = "wyvern-schema", to = "wyvern-cli" }, + { from = "wyvern-schema", to = "wyvern-wizard" }, + { from = "wyvern-schema", to = "wyvern-mcp" }, + { from = "wyvern-schema", to = "wry" }, + { from = "wyvern-schema", to = "winit" }, + { from = "wyvern-schema", to = "rfd" }, +] + +[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 40b26981..9eaa1bd8 100644 --- a/boundaries/wyvern-viewer/viewer.toml +++ b/boundaries/wyvern-viewer/viewer.toml @@ -1,16 +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_dependencies = ["wyvern", "wyvern-mcp", "wyvern-host", "wyvern-schema"] -forbidden_edges = ["wyvern-viewer -> wyvern-host", "wyvern-viewer -> wyvern-schema"] +forbidden_edges = [ -[ownership] -io_owns = ["webview_open_url", "webview_show_hide", "viewer_lifecycle_stdin", "chrome_presentation_ipc"] -io_forbidden = ["http_server", "stdout_writing", "inline_html"] + { from = "wyvern-viewer", to = "wyvern-cli" }, + { from = "wyvern-viewer", to = "wyvern-mcp" }, + { from = "wyvern-viewer", to = "wyvern-host" }, + { from = "wyvern-viewer", to = "wyvern-schema" }, +] + +[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 1cd4ee56..47db94bb 100644 --- a/boundaries/wyvern-wizard/wizard.toml +++ b/boundaries/wyvern-wizard/wizard.toml @@ -1,34 +1,59 @@ 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_dependencies = ["wyvern", "wyvern-window", "wyvern-host", "wyvern-mcp", "wry", "winit", "rfd", "axum", "tokio", "tower", "hyper", "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", +forbidden_edges = [ + + { from = "wyvern-wizard", to = "wyvern-cli" }, + { from = "wyvern-wizard", to = "wyvern-host" }, + { from = "wyvern-wizard", to = "wyvern-mcp" }, + { from = "wyvern-wizard", to = "wry" }, + { from = "wyvern-wizard", to = "winit" }, + { from = "wyvern-wizard", to = "rfd" }, + { from = "wyvern-wizard", to = "axum" }, + { from = "wyvern-wizard", to = "tokio" }, + { from = "wyvern-wizard", to = "tower" }, + { from = "wyvern-wizard", to = "hyper" }, + { from = "wyvern-wizard", to = "reqwest" }, ] -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/boundaries/wyvern/cli.toml b/boundaries/wyvern/cli.toml deleted file mode 100644 index 912976d5..00000000 --- a/boundaries/wyvern/cli.toml +++ /dev/null @@ -1,31 +0,0 @@ -boundary_id = "BOUNDARY-WyvernCli" -owner_package = "wyvern-cli" -name = "WyvernCli" - -[dependencies] -# wyvern-viewer: optional dep for dev binary-path helpers; embedded spawn uses subprocess (no wry in CLI) -allowed_dependencies = [ - "wyvern-schema", - "wyvern-host", - "wyvern-wizard", - "wyvern-viewer", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", - "sc-observability", - "sc-observability-types", - "dirs", - "rust-embed", - "tempfile", - "libc", -] -forbidden_dependencies = ["wyvern-mcp", "wry", "winit", "rfd"] -forbidden_edges = ["wyvern-cli -> wyvern-mcp"] - -[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"] - -[enforcement] -lint_rules = ["LINT-BOUNDARY-CLI-NO-WINDOW-DIRECT"] 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 e7006842..6c31382c 100755 --- a/scripts/check-boundaries.py +++ b/scripts/check-boundaries.py @@ -1,24 +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 - - 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. """ @@ -31,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"), @@ -51,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"), @@ -88,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) @@ -105,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(): @@ -116,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(): @@ -136,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 @@ -154,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")) @@ -162,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: @@ -172,70 +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 []) - 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"] From b24189d128a367e8076ba10159d364d984940155 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 6 Sep 2026 18:27:57 -0700 Subject: [PATCH 2/4] Fix setup-sc-lint version contract for sc-lint 0.5.0 JSON schema. The 0.5.0 release reports data.version instead of data.crate_version; accept either field in the composite action and align the lint-toolchain default pin. Co-authored-by: Cursor --- .../actions/setup-lint-toolchain/action.yml | 2 +- .github/actions/setup-sc-lint/action.yml | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) 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 f5aec186..55bcc929 100644 --- a/.github/actions/setup-sc-lint/action.yml +++ b/.github/actions/setup-sc-lint/action.yml @@ -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' From 6b2d21e4996efdc35ca89b29592635a7d1025e69 Mon Sep 17 00:00:00 2001 From: Rand Date: Wed, 7 Oct 2026 17:33:02 -0700 Subject: [PATCH 3/4] ci(sc-lint): land shared 0.5.0 matrix clippy gate and setup helpers. Wire JSON-gated sc-lint clippy native on the build matrix and verify pinned Python helpers so later stack layers can reuse this infrastructure. Co-authored-by: Cursor --- .gitattributes | 8 ++++++++ .github/actions/setup-sc-lint/action.yml | 11 +++++++++++ .github/workflows/ci.yml | 6 +++--- .gitignore | 2 ++ docs/linting.md | 10 ++++++---- scripts/sc_lint_json_gate.sh | 19 +++++++++++++++++++ 6 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 .gitattributes create mode 100755 scripts/sc_lint_json_gate.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c2c152c6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# sc-lint init --just --check compares these product-managed files +# byte-for-byte against the published LF canonical integration. +# Git for Windows defaults to core.autocrlf=true, which would rewrite +# them to CRLF and fail with CLI.SC_LINT_INTEGRATION_CONFLICT. +sc-lint.toml text eol=lf +Justfile text eol=lf +.sc-lint/bootstrap text eol=lf +.sc-lint/bootstrap.ps1 text eol=lf diff --git a/.github/actions/setup-sc-lint/action.yml b/.github/actions/setup-sc-lint/action.yml index 55bcc929..d5dc7f37 100644 --- a/.github/actions/setup-sc-lint/action.yml +++ b/.github/actions/setup-sc-lint/action.yml @@ -138,6 +138,12 @@ runs: cp "${source_root}/.just/"*.py "${GITHUB_WORKSPACE}/.just/" printf '%s\n' "${SC_LINT_VERSION}" > "${GITHUB_WORKSPACE}/.just/.sc-lint-runtime-version" test -f "${GITHUB_WORKSPACE}/.just/run_lint.py" + for helper in lint_line_counts.py lint_identity_literals.py; do + test -f "${GITHUB_WORKSPACE}/.just/${helper}" || { + echo "sc-lint setup: source archive is missing .just/${helper}" >&2 + exit 1 + } + done - name: Materialize pinned sc-lint Python utilities (Windows) if: runner.os == 'Windows' shell: pwsh @@ -158,6 +164,11 @@ runs: if (-not (Test-Path (Join-Path $justRoot 'run_lint.py'))) { throw 'sc-lint setup: source archive is missing .just/run_lint.py' } + foreach ($helper in @('lint_line_counts.py', 'lint_identity_literals.py')) { + if (-not (Test-Path (Join-Path $justRoot $helper))) { + throw "sc-lint setup: source archive is missing .just/$helper" + } + } - name: Smoke-test repository root discovery (Unix) if: runner.os != 'Windows' shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3932824c..357fbe52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,12 +45,12 @@ jobs: - name: cargo test run: cargo test --workspace -- --test-threads=1 - - name: cargo clippy - run: cargo clippy --workspace -- -D warnings - - name: Set up sc-lint (published release bundle) uses: ./.github/actions/setup-sc-lint + - name: sc-lint clippy native + run: bash scripts/sc_lint_json_gate.sh pass -- clippy native --config .sc-lint.toml + - name: sc-lint check run: sc-lint check native --config .sc-lint.toml diff --git a/.gitignore b/.gitignore index 9267198b..2562308f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /target /crates/wyvern/target +.just/ +artifacts/ /dist Cargo.lock **/*.rs.bk diff --git a/docs/linting.md b/docs/linting.md index f2ea5233..f32d07e9 100644 --- a/docs/linting.md +++ b/docs/linting.md @@ -54,6 +54,7 @@ multiple webview children spawn). CI already enforces this; local runs must matc | Backend | CLI target | Wyvern CI | |---------|------------|-----------| | Compile gate | `sc-lint check native` | **Yes** — all matrix legs | +| Clippy wrapper | `sc-lint clippy native` | **Yes** — all build 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 | @@ -72,14 +73,14 @@ Production paths must not panic. Panics are forbidden in non-test code in | 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 clippy native` | **Indirect** — wraps `cargo clippy -D warnings`; honors crate `#![deny(...)]` | Yes (matrix) | | `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: 1. Crate-root `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unreachable, clippy::todo, clippy::unimplemented))]` on the four roots above -2. Existing `cargo clippy --workspace -- -D warnings` in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) +2. `sc-lint clippy native --config .sc-lint.toml` in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) (JSON-gated) `#![allow(...)]` for these lints is permitted only inside `#[cfg(test)]` modules. @@ -92,8 +93,9 @@ sc-lint clippy native --config .sc-lint.toml ## CI Every matrix leg (`ubuntu-latest`, `macos-latest`, `windows-latest`) installs -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). +sc-lint **0.5.0** from the GitHub release bundle, runs **`sc-lint clippy native`** +(JSON gate via [`scripts/sc_lint_json_gate.sh`](../scripts/sc_lint_json_gate.sh)), +then **`sc-lint check native`**. 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/sc_lint_json_gate.sh b/scripts/sc_lint_json_gate.sh new file mode 100755 index 00000000..89655b54 --- /dev/null +++ b/scripts/sc_lint_json_gate.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Gate sc-lint --json commands that exit 0 when data.status is not pass/current. +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$2" != "--" ]; then + echo "usage: sc_lint_json_gate.sh -- " >&2 + echo "example: sc_lint_json_gate.sh pass -- lint sc-boundary --config .sc-lint.toml" >&2 + exit 2 +fi + +expected_status="$1" +shift 2 + +report="$(sc-lint --json "$@")" +printf '%s\n' "${report}" + +jq -e --arg expected "${expected_status}" \ + '.ok == true and .data.status == $expected' \ + <<<"${report}" >/dev/null From 75d1612ee256c0d0e2745e60584883f34855b26c Mon Sep 17 00:00:00 2001 From: Rand Date: Wed, 7 Oct 2026 17:54:46 -0700 Subject: [PATCH 4/4] fix(test): gate PathBuf import to unix in extension_diagnostics. Windows clippy with --all-targets fails on unused PathBuf under sc-lint clippy native (-D warnings). Co-authored-by: Cursor --- crates/wyvern/tests/extension_diagnostics.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/wyvern/tests/extension_diagnostics.rs b/crates/wyvern/tests/extension_diagnostics.rs index 844b9d63..6b59fdb0 100644 --- a/crates/wyvern/tests/extension_diagnostics.rs +++ b/crates/wyvern/tests/extension_diagnostics.rs @@ -1,7 +1,9 @@ //! Subprocess tests for g.2 near-miss diagnostics (REQ-0130, REQ-0136). use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; +#[cfg(unix)] +use std::path::PathBuf; use std::process::Command; fn wyvern() -> Command {