diff --git a/.cargo/config.toml b/.cargo/config.toml index 2b05450..72bb4f4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,15 +1,19 @@ [alias] xtask = "run -p xtask --" +validate-bootstrap = "xtask validate bootstrap" +validate-changed = "xtask validate changed" +validate-doctor = "xtask validate doctor" +validate-install-hooks = "xtask validate install-hooks" ui-dev = "xtask ui dev" ui-build = "xtask ui build" ui-harden = "xtask ui-hardening" tauri-dev = "xtask tauri dev" tauri-build = "xtask tauri build" components-build = "xtask components build" -verify-fast = "xtask verify profile fast" -verify-repo = "xtask verify profile repo" -verify-ui = "xtask verify profile ui" -verify-full = "xtask verify profile full" +verify-fast = "xtask validate suite core" +verify-repo = "xtask validate suite governance security core" +verify-ui = "xtask validate suite ui" +verify-full = "xtask validate suite full" rust-audit = "xtask rust audit" rust-clean = "xtask rust clean" rust-trace = "xtask rust trace" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..17c2ab3 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "${repo_root}" + +cargo xtask validate changed --fetch-base diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 46436b0..8edd576 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -6,7 +6,7 @@ ## When Editing This Plane - Update `.github/governance.toml`, templates, and workflow checks together when policy changes. -- Keep `Governance / validate` authoritative for process enforcement; prefer extending `xtask` over adding one-off shell scripts. +- Keep `Governance / governance-gate` authoritative for process enforcement; prefer extending `xtask` over adding one-off shell scripts. - Re-run `cargo xtask github audit-process` after any template or workflow change. ## Required Companion Updates diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8f2878e..bb95ff4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -65,9 +65,16 @@ Closes # - [ ] This branch is based on the current target branch (`origin/main` for normal PRs, the parent branch for stacked PRs). - [ ] If this PR is stacked, the PR base points to the parent branch until that parent work merges. -- [ ] If this PR touches `ui/crates/desktop_runtime`, `ui/crates/system_ui`, or `ui/crates/site/src/generated`, I rebased immediately before requesting merge. -- [ ] If this PR touches `ui/`, `shared/`, `platform/`, `schemas/`, `.github/`, or `infrastructure/wasmcloud/manifests`, I refreshed from the latest target branch and reran validation immediately before requesting merge. -- [ ] If this PR updates generated assets or token outputs, I regenerated them after the last rebase. +- [ ] If this PR touches `ui/crates/desktop_runtime`, `ui/crates/system_ui`, `shared/`, `platform/`, `schemas/`, `.github/`, or `infrastructure/wasmcloud/manifests`, I refreshed from the latest target branch and reran validation immediately before requesting merge. +- [ ] If this PR changes shell, token, or Tailwind inputs, I regenerated the local derived UI outputs after the last rebase and did not commit repo-generated CSS/token files. +- [ ] The repository pre-push hook is installed locally, or I am disclosing below why it was bypassed. + +## Local Validation + +- `cargo xtask validate changed`: pass/fail +- `cargo xtask github validate-pr-local`: pass/fail +- `git push --no-verify` used: no/yes +- If `git push --no-verify` was used, document the incident, rationale, and follow-up issue here. ## Technical Changes diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index fc2af0d..b3b8ea0 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -14,6 +14,10 @@ inputs: description: Node.js version to install. Empty skips Node setup. required: false default: "" + node-version-file: + description: File that declares the Node.js version to install. Empty skips version-file setup. + required: false + default: "" node-cache-path: description: npm lockfile path used for cache setup. Empty disables npm cache wiring. required: false @@ -22,34 +26,73 @@ inputs: runs: using: composite steps: + - name: Validate Node inputs + if: ${{ inputs.node-version != '' && inputs.node-version-file != '' }} + shell: bash + run: | + echo "Provide either node-version or node-version-file, not both." + exit 1 + - name: Setup Node - if: ${{ inputs.node-version != '' && inputs.node-cache-path == '' }} + if: ${{ inputs.node-version != '' && inputs.node-version-file == '' && inputs.node-cache-path == '' }} uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - name: Setup Node with npm cache - if: ${{ inputs.node-version != '' && inputs.node-cache-path != '' }} + if: ${{ inputs.node-version != '' && inputs.node-version-file == '' && inputs.node-cache-path != '' }} uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: npm cache-dependency-path: ${{ inputs.node-cache-path }} - - name: Install Rust toolchain - if: ${{ inputs.rust-components == '' }} - uses: dtolnay/rust-toolchain@1.91.1 + - name: Setup Node from version file + if: ${{ inputs.node-version == '' && inputs.node-version-file != '' && inputs.node-cache-path == '' }} + uses: actions/setup-node@v4 + with: + node-version-file: ${{ inputs.node-version-file }} - - name: Install Rust toolchain with components - if: ${{ inputs.rust-components != '' }} - uses: dtolnay/rust-toolchain@1.91.1 + - name: Setup Node from version file with npm cache + if: ${{ inputs.node-version == '' && inputs.node-version-file != '' && inputs.node-cache-path != '' }} + uses: actions/setup-node@v4 with: - components: ${{ inputs.rust-components }} + node-version-file: ${{ inputs.node-version-file }} + cache: npm + cache-dependency-path: ${{ inputs.node-cache-path }} + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + + toolchain="$(sed -n 's/^channel = \"\\(.*\\)\"$/\\1/p' rust-toolchain.toml)" + if [[ -z "${toolchain}" ]]; then + echo "failed to resolve Rust toolchain channel from rust-toolchain.toml" + exit 1 + fi + + rustup toolchain install "${toolchain}" --profile minimal --target wasm32-unknown-unknown + + while IFS= read -r component; do + [[ -z "${component}" ]] && continue + rustup component add --toolchain "${toolchain}" "${component}" + done < <(sed -n 's/^components = \\[\\(.*\\)\\]$/\\1/p' rust-toolchain.toml | tr -d '" ' | tr ',' '\n') + + if [[ -n "${{ inputs.rust-components }}" ]]; then + IFS=',' read -r -a extra_components <<< "${{ inputs.rust-components }}" + for component in "${extra_components[@]}"; do + [[ -z "${component}" ]] && continue + rustup component add --toolchain "${toolchain}" "${component}" + done + fi - - name: Cache cargo - uses: Swatinem/rust-cache@v2 + - name: Cache cargo registry + uses: actions/cache@v4 with: - shared-key: ${{ inputs.rust-cache-shared-key }} - cache-all-crates: "false" - cache-bin: "false" - cache-workspace-crates: "false" + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-${{ inputs.rust-cache-shared-key }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + ${{ runner.os }}-${{ inputs.rust-cache-shared-key }}- diff --git a/.github/governance.toml b/.github/governance.toml index 39b9b18..bca3224 100644 --- a/.github/governance.toml +++ b/.github/governance.toml @@ -11,14 +11,15 @@ branch_name_pattern = "^(feature|fix|infra|docs|refactor|research)/[0-9]+-[a-z0- pr_title_pattern = "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|test)\\([a-z0-9][a-z0-9_-]*\\): [a-z0-9].+$" # `xtask github sync repo` applies these checks with strict status enforcement so PR heads # must be current with the target branch before merge. -required_status_checks = ["Governance / validate", "CI / pr-gate", "Security / security-gate"] +required_status_checks = ["Governance / governance-gate", "CI / pr-gate", "Security / security-gate"] # `main` requires one approval and code owner review. Stacked PRs should target their # parent branch until the base PR merges instead of pointing multiple layers at `main`. required_approving_review_count = 1 dismiss_stale_reviews_on_push = true require_code_owner_review = true required_review_thread_resolution = true -# Keep one merge path on `main`; merge queue is enabled separately in GitHub UI. +# Keep one merge path on `main`; merge queue must remain enabled in GitHub UI and should be the +# default merge path after local-first validation succeeds. allow_auto_merge = true allow_squash_merge = true allow_merge_commit = false diff --git a/.github/security-exceptions.toml b/.github/security-exceptions.toml new file mode 100644 index 0000000..0bb8d02 --- /dev/null +++ b/.github/security-exceptions.toml @@ -0,0 +1,76 @@ +version = 1 + +[[exceptions]] +ids = ["RUSTSEC-2023-0071"] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive through SurrealDB and jsonwebtoken; there is no fixed upstream version available yet." + +[[exceptions]] +ids = ["RUSTSEC-2021-0046"] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "cargo-audit matches the workspace crate name `telemetry` against an unrelated external advisory; this is a false positive for the local crate." + +[[exceptions]] +ids = [ + "RUSTSEC-2024-0411", + "RUSTSEC-2024-0412", + "RUSTSEC-2024-0413", + "RUSTSEC-2024-0414", + "RUSTSEC-2024-0415", + "RUSTSEC-2024-0416", + "RUSTSEC-2024-0417", + "RUSTSEC-2024-0418", + "RUSTSEC-2024-0419", + "RUSTSEC-2024-0420", + "RUSTSEC-2024-0429", + "RUSTSEC-2024-0370", +] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive GTK3 and proc-macro warnings from the current Tauri and WRY desktop host stack; remediation requires upstream migration rather than a local patch." + +[[exceptions]] +ids = ["RUSTSEC-2023-0089"] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive through the current SurrealDB geo stack; no repo-local replacement is available without a larger upstream dependency move." + +[[exceptions]] +ids = ["RUSTSEC-2025-0141", "RUSTSEC-2026-0002"] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive through memvid-core and related storage/search dependencies; replacement requires a coordinated dependency upgrade beyond this validation rollout." + +[[exceptions]] +ids = ["RUSTSEC-2025-0057"] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive through tauri-utils in the current desktop host stack; no repo-local fix is available without an upstream migration." + +[[exceptions]] +ids = ["RUSTSEC-2024-0436"] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive through the current Leptos and Tachys macro stack; replacement must follow upstream framework guidance." + +[[exceptions]] +ids = [ + "RUSTSEC-2025-0075", + "RUSTSEC-2025-0080", + "RUSTSEC-2025-0081", + "RUSTSEC-2025-0098", + "RUSTSEC-2025-0100", +] +owner = "@justinrayshort" +issue = 139 +expires = "2026-06-30" +reason = "Transitive through the current urlpattern and Tauri utility stack; remediation depends on upstream replacements for the archived unicode crates." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def791d..48da47c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,146 +21,45 @@ jobs: with: fetch-depth: 0 - - name: Detect changed areas - id: filter - if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} - uses: dorny/paths-filter@v3 - with: - filters: | - shared_root: - - '.cargo/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'README.md' - - 'CONTRIBUTING.md' - - 'DEVELOPMENT_MODEL.md' - - 'ARCHITECTURE.md' - - '.github/workflows/**' - - '.github/actions/**' - - 'xtask/**' - - 'shared/**' - - 'schemas/**' - - 'platform/**' - - 'enterprise/**' - - 'workflows/**' - core_rust: - - 'services/**' - - 'agents/**' - ui: - - 'ui/**' - nomad: - - 'infrastructure/nomad/**/*.hcl' - pulumi: - - 'infrastructure/pulumi/**' - - - name: Resolve validation scope - id: scope - shell: bash - run: | - if [[ "${GITHUB_EVENT_NAME}" == "merge_group" || "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - shared_root=true - core_rust=true - ui=true - nomad=true - pulumi=true - else - shared_root="${{ steps.filter.outputs.shared_root }}" - core_rust="${{ steps.filter.outputs.core_rust }}" - ui="${{ steps.filter.outputs.ui }}" - nomad="${{ steps.filter.outputs.nomad }}" - pulumi="${{ steps.filter.outputs.pulumi }}" - fi - - if [[ "${shared_root}" == "true" || "${core_rust}" == "true" ]]; then - run_core=true - else - run_core=false - fi - - if [[ "${shared_root}" == "true" || "${ui}" == "true" ]]; then - run_ui=true - else - run_ui=false - fi - - { - echo "run_core=${run_core}" - echo "run_ui=${run_ui}" - echo "run_nomad=${nomad}" - echo "run_pulumi=${pulumi}" - } >> "${GITHUB_OUTPUT}" - - name: Setup build environment uses: ./.github/actions/setup-build-environment with: rust-components: rustfmt,clippy - node-version: "20" - node-cache-path: ui/e2e/package-lock.json + node-version-file: .nvmrc + node-cache-path: | + ui/e2e/package-lock.json + infrastructure/pulumi/package-lock.json - - name: Install UI verification dependencies - if: ${{ steps.scope.outputs.run_ui == 'true' }} - run: npm ci --prefix ui/e2e - - - name: Install Playwright browsers for browser hardening validation - if: ${{ steps.scope.outputs.run_ui == 'true' }} - run: npx --prefix ui/e2e playwright install --with-deps chromium firefox webkit - - - name: Run core verification profile - if: ${{ steps.scope.outputs.run_core == 'true' }} - run: cargo xtask verify profile core - - - name: Run UI verification profile - if: ${{ steps.scope.outputs.run_ui == 'true' }} - run: cargo xtask verify profile ui - - - name: Run UI hardening verification - if: ${{ steps.scope.outputs.run_ui == 'true' }} - run: cargo xtask ui-hardening - - - name: Infrastructure posture validation - if: ${{ steps.scope.outputs.run_core == 'true' }} - run: | - if rg -n 'driver\s*=\s*"raw_exec"' infrastructure/nomad/jobs; then - echo "raw_exec deployments are not allowed for workload services" - exit 1 - fi - - - name: Validate Nomad jobs - if: ${{ steps.scope.outputs.run_nomad == 'true' }} + - name: Run repo-owned CI validation shell: bash run: | - mapfile -t files < <(find infrastructure/nomad/jobs -type f -name '*.nomad.hcl' | sort) - if [[ "${#files[@]}" -eq 0 ]]; then - echo "No Nomad jobs found." - exit 0 - fi - for file in "${files[@]}"; do - docker run --rm -v "${PWD}:/workspace" -w /workspace hashicorp/nomad:1.8.4 \ - nomad job validate "${file}" - done + command=(cargo xtask validate ci --event "${GITHUB_EVENT_NAME}") - - name: Install Pulumi dependencies - if: ${{ steps.scope.outputs.run_pulumi == 'true' }} - working-directory: infrastructure/pulumi - run: npm ci + case "${GITHUB_EVENT_NAME}" in + pull_request) + command+=(--base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}") + ;; + push) + command+=(--base "${{ github.event.before }}" --head "${{ github.sha }}") + ;; + esac - - name: Run Pulumi workspace tests - if: ${{ steps.scope.outputs.run_pulumi == 'true' }} - working-directory: infrastructure/pulumi - run: npm test + "${command[@]}" - - name: Summarize path-scoped validation + - name: Publish validation summary if: always() shell: bash run: | - { - echo "## Path-scoped validation summary" - echo "" - echo "- core verification: ${{ steps.scope.outputs.run_core }}" - echo "- ui verification: ${{ steps.scope.outputs.run_ui }}" - echo "- nomad validation: ${{ steps.scope.outputs.run_nomad }}" - echo "- pulumi validation: ${{ steps.scope.outputs.run_pulumi }}" - echo "" - echo "The top-level required check remains \`CI / pr-gate\` even when individual validation areas are skipped." - } >> "${GITHUB_STEP_SUMMARY}" + if [[ -f target/validation/ci.md ]]; then + cat target/validation/ci.md >> "${GITHUB_STEP_SUMMARY}" + else + echo "No CI validation report was produced." >> "${GITHUB_STEP_SUMMARY}" + fi + + - name: Upload validation reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: validation-reports-ci + path: target/validation + if-no-files-found: ignore diff --git a/.github/workflows/delivery-dev.yml b/.github/workflows/delivery-dev.yml index e127a92..4b22403 100644 --- a/.github/workflows/delivery-dev.yml +++ b/.github/workflows/delivery-dev.yml @@ -27,7 +27,7 @@ jobs: - name: Setup build environment uses: ./.github/actions/setup-build-environment with: - node-version: "20" + node-version-file: .nvmrc node-cache-path: infrastructure/pulumi/package-lock.json - name: Setup delivery environment diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 5696ee4..ad5a856 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -9,8 +9,8 @@ permissions: contents: read jobs: - validate: - name: validate + governance-gate: + name: governance-gate runs-on: ubuntu-latest steps: - name: Checkout @@ -21,22 +21,37 @@ jobs: - name: Setup build environment uses: ./.github/actions/setup-build-environment - - name: Audit architecture boundaries - run: cargo xtask architecture audit-boundaries - - - name: Validate governed plugin manifests - run: cargo xtask plugin validate-manifests - - - name: Audit documented process against automation - run: cargo xtask github audit-process + - name: Run repo-owned governance validation + run: cargo xtask validate suite governance - name: Validate PR governance if: ${{ github.event_name == 'pull_request' }} run: cargo xtask github validate-pr --event-path "$GITHUB_EVENT_PATH" --config .github/governance.toml + - name: Publish governance summary + if: always() + shell: bash + run: | + if [[ -f target/validation/suite.md ]]; then + cat target/validation/suite.md >> "${GITHUB_STEP_SUMMARY}" + fi + if [[ -f target/process-audit/report.md ]]; then + echo "" >> "${GITHUB_STEP_SUMMARY}" + cat target/process-audit/report.md >> "${GITHUB_STEP_SUMMARY}" + fi + - name: Upload process audit artifacts if: always() uses: actions/upload-artifact@v4 with: name: process-audit path: target/process-audit + if-no-files-found: ignore + + - name: Upload validation reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: validation-reports-governance + path: target/validation + if-no-files-found: ignore diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index a142b62..aaeb957 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -47,7 +47,7 @@ jobs: - name: Setup build environment uses: ./.github/actions/setup-build-environment with: - node-version: "20" + node-version-file: .nvmrc node-cache-path: infrastructure/pulumi/package-lock.json - name: Setup delivery environment diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 3f67cb7..d770b80 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -49,7 +49,7 @@ jobs: - name: Setup build environment uses: ./.github/actions/setup-build-environment with: - node-version: "20" + node-version-file: .nvmrc node-cache-path: infrastructure/pulumi/package-lock.json - name: Setup delivery environment @@ -64,7 +64,7 @@ jobs: - name: Rebuild and verify target SHA run: | - cargo xtask verify profile core + cargo xtask validate suite core cargo components-build cargo test -p wasmcloud-bindings -p surrealdb-access diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index efec5bf..1277ed5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,11 +15,6 @@ jobs: security-gate: name: security-gate runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - env: - CARGO_AUDIT_VERSION: 0.22.1 steps: - name: Checkout uses: actions/checkout@v4 @@ -27,20 +22,23 @@ jobs: - name: Setup build environment uses: ./.github/actions/setup-build-environment - - name: Dependency review - if: ${{ github.event_name == 'pull_request' }} - uses: actions/dependency-review-action@v4 - - - name: Cache cargo-audit - id: cargo-audit-cache - uses: actions/cache@v4 + - name: Run repo-owned security validation + run: cargo xtask validate suite security + + - name: Publish security summary + if: always() + shell: bash + run: | + if [[ -f target/validation/security.md ]]; then + cat target/validation/security.md >> "${GITHUB_STEP_SUMMARY}" + else + echo "No security validation report was produced." >> "${GITHUB_STEP_SUMMARY}" + fi + + - name: Upload validation reports + if: always() + uses: actions/upload-artifact@v4 with: - path: ~/.cargo/bin/cargo-audit - key: ${{ runner.os }}-cargo-audit-${{ env.CARGO_AUDIT_VERSION }} - - - name: Install cargo-audit - if: steps.cargo-audit-cache.outputs.cache-hit != 'true' - run: cargo install cargo-audit --locked --version "${CARGO_AUDIT_VERSION}" - - - name: Run cargo audit - run: cargo audit + name: validation-reports-security + path: target/validation + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 95430f6..ffac7e3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ coverage/ .vscode/ .idea/ +# Generated UI token and browser styling artifacts +ui/crates/site/src/generated/ +ui/crates/site/tailwind.config.js + # Local task ledgers and spec notes completion-todo.txt complex-task.txt diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8465e4f..45e8a40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,11 +35,13 @@ All material changes are issue-driven and must follow the same GitHub workflow: merge quickly. 8. Keep each branch focused on one dominant subsystem or one explicitly sequenced cross-layer objective. -9. For long, multi-step, multi-plane, or `high` risk-class work, add execution artifacts under +9. Install the blocking local hook once per clone with `cargo xtask validate install-hooks` so + pushes run the repo-owned changed-scope validation gate before reaching GitHub. +10. For long, multi-step, multi-plane, or `high` risk-class work, add execution artifacts under `plans/-/` using the templates in `plans/templates/`. -10. If the work is stacked, create the child branch from its parent branch and open the child PR against the parent branch until the base PR lands. -11. Rebase on the current target branch before requesting merge, and rebase again if the target branch moves while the PR is open. -12. Open a pull request targeting `main` or the parent branch in a stack that: +11. If the work is stacked, create the child branch from its parent branch and open the child PR against the parent branch until the base PR lands. +12. Rebase on the current target branch before requesting merge, and rebase again if the target branch moves while the PR is open. +13. Open a pull request targeting `main` or the parent branch in a stack that: - references the originating issue, - records the execution artifact status or matching plan bundle path, - records ADR references and impacted domains, @@ -48,9 +50,10 @@ All material changes are issue-driven and must follow the same GitHub workflow: - records the rollback path and validation artifacts, - documents the testing strategy, - records the risk class, + - discloses any `git push --no-verify` bypass incident, - includes an `Architecture Delta` section when the PR spans multiple architectural planes, - includes a closing directive such as `Closes #`. -13. Merge only after review and required checks pass so GitHub automatically closes the linked issue. +14. Merge only after review, merge-queue admission, and required checks pass so GitHub automatically closes the linked issue. Example commands: @@ -79,11 +82,11 @@ gh pr create --fill - Squash merge is the default merge strategy. - Keep the `Closes #` directive in the PR body through merge so the issue closes automatically. - If the PR is stacked, target the parent branch until the base PR merges. -- If the PR touches `ui/crates/desktop_runtime`, `ui/crates/system_ui`, or `ui/crates/site/src/generated`, rebase on the current target branch immediately before requesting merge. +- If the PR touches `ui/crates/desktop_runtime` or `ui/crates/system_ui`, rebase on the current target branch immediately before requesting merge. - If the PR touches shell composition, `shared/`, `platform/`, `schemas/`, `.github/`, or `infrastructure/wasmcloud/manifests`, refresh from the latest target branch and rerun validation immediately before requesting merge. -- Regenerate derived assets after the last rebase and before review whenever the PR changes token, shell, or generated CSS inputs. +- Regenerate derived assets after the last rebase and before review whenever the PR changes token, shell, or generated CSS inputs, but do not commit repo-generated UI CSS/token outputs. - Do not mix unrelated shell refactors, generated asset churn, and behavioral fixes into one PR when they can be reviewed separately. - Multi-plane PRs must explain why the change could not be split into a narrower sequence. @@ -123,18 +126,34 @@ Run the baseline checks from the repository root: cargo verify-repo ``` +Install the blocking local hook once per clone: + +```bash +cargo xtask validate install-hooks +``` + +Use the changed-scope local gate while developing and before each push: + +```bash +cargo xtask validate changed +cargo xtask github validate-pr-local --title "type(scope): summary" --body-file /tmp/pr-body.md +``` + If the change touches `ui/`, also run: ```bash -cargo xtask verify profile ui -cargo xtask ui-hardening +cargo verify-ui +cargo xtask validate suite ui-hardening ``` -If your change affects dependency security posture, validate `cargo audit` locally or rely on the `Security Scan` workflow in CI. +Do not treat GitHub Actions as the first place to discover routine validation failures. `git push --no-verify` is an emergency escape hatch only and must be disclosed in the PR body. + +`cargo verify-repo` now covers governance, security, and core validation through the repo-owned +`cargo xtask validate` framework. The required GitHub checks are: -- `Governance / validate` +- `Governance / governance-gate` - `CI / pr-gate` - `Security / security-gate` diff --git a/Cargo.lock b/Cargo.lock index 0294397..4eb4c9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12425,6 +12425,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "base64 0.22.1", + "chrono", "jsonschema", "lattice-config", "regex", diff --git a/DEVELOPMENT_MODEL.md b/DEVELOPMENT_MODEL.md index 7bfda62..340e4a4 100644 --- a/DEVELOPMENT_MODEL.md +++ b/DEVELOPMENT_MODEL.md @@ -134,11 +134,12 @@ PRs that touch multiple architectural planes must also include: Merge policy: - minimum reviewers: 1 +- merge queue: required on `main` (configured in GitHub UI) - squash merge: required - direct pushes to `main`: prohibited - dismiss stale approvals when new commits are pushed - code owner review: required -- auto-merge: enabled as the fallback path when merge queue is unavailable +- auto-merge: may remain enabled operationally, but contributors should use the queue on `main` - merging the PR closes the linked issue through the PR closing directive ## CI/CD Baseline @@ -150,22 +151,34 @@ Required CI stages: - security gate - delivery promotion -Baseline checks: +Repo-owned validation surfaces: ```bash -cargo fmt --all --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace --all-targets -cargo audit +cargo verify-repo +``` + +For UI work: + +```bash +cargo verify-ui +cargo xtask validate suite ui-hardening +``` + +Local-first workflow: + +```bash +cargo xtask validate install-hooks +cargo xtask validate changed +cargo xtask github validate-pr-local --title "type(scope): summary" --body-file /tmp/pr-body.md ``` CI optimization policy: +- Local and GitHub validation share the same suite-selection model through `cargo xtask validate changed` and `cargo xtask validate ci`. - Rust validation is path-scoped: - backend-only Rust changes run the core validation profile, UI-only changes run the UI validation profile, and shared/root changes run the full workspace validation plus browser preview build. + backend-only Rust changes run the core suite, UI-only changes run the UI plus UI-hardening suites, and shared/root changes run the combined repo-owned suites selected by the validation matrix. - GitHub Actions reuses shared Rust dependency caches across CI, delivery, release, and security workflows with stable shared cache keys. -- Small cargo-installed tools are cached explicitly: - `trunk` is cached for UI/browser preview builds and `cargo-audit` is cached for the security workflow. +- Small cargo-installed tools are prepared through `cargo xtask validate bootstrap` locally and installed on demand by the repo-owned suites in GitHub. - CI keeps incremental compilation disabled so cache storage is spent on reusable dependency artifacts instead of large incremental state that churns quickly on hosted runners. - `sccache` and `target/` artifact reuse were evaluated but are not enabled in the current baseline: the workspace artifact footprint is too large for GitHub's cache budget to make those approaches efficient without an external cache backend. @@ -175,7 +188,7 @@ CI optimization policy: Required status checks: -- `Governance / validate` +- `Governance / governance-gate` - `CI / pr-gate` - `Security / security-gate` @@ -193,6 +206,9 @@ The canonical non-UI local validation surface is: cargo verify-repo ``` +`cargo verify-repo` now covers governance, security, and core validation through the shared +repo-owned validation framework. + Rust workspace hygiene and tracing workflows are repository-owned through: ```bash diff --git a/README.md b/README.md index 39fe466..180d7fb 100644 --- a/README.md +++ b/README.md @@ -83,19 +83,28 @@ Run from the repository root: cargo verify-repo ``` -`cargo verify-repo` is the canonical non-UI validation surface. The GitHub CI baseline also -includes `cargo audit`. +`cargo verify-repo` remains the canonical non-UI validation alias and now resolves to the +repo-owned `cargo xtask validate` framework, including governance, security, and core validation. -For local enforcement parity, also run: +For local-first parity, install the hook once and use the changed-scope gate during development: ```bash -cargo xtask verify profile ui -cargo xtask ui-hardening +cargo xtask validate install-hooks +cargo xtask validate changed +cargo xtask github validate-pr-local --title "type(scope): summary" --body-file /tmp/pr-body.md ``` -For Rust workspace hygiene and tracing workflows, use: +If the change touches `ui/`, also run: ```bash +cargo verify-ui +cargo xtask validate suite ui-hardening +``` + +For bootstrap, Rust workspace hygiene, and tracing workflows, use: + +```bash +cargo xtask validate bootstrap cargo rust-audit cargo rust-clean incremental cargo rust-trace desktop --dry-run @@ -110,7 +119,9 @@ workspace artifact growth under control. Origin uses a GitHub-native, trunk-based delivery model: - `main` is the only long-lived branch. -- `CI / pr-gate`, `Security / security-gate`, and `Governance / validate` are the required checks. +- Merge queue is the required merge path for `main`; required checks should confirm already-green + branches rather than discover routine failures. +- `CI / pr-gate`, `Security / security-gate`, and `Governance / governance-gate` are the required checks. - The browser/PWA runtime is the baseline platform surface; the Tauri host extends the same surface with desktop-only capabilities instead of forking the product model. - The `Delivery Dev` workflow runs automatically on pushes to `main`, publishes diff --git a/docs/architecture/layer-boundaries.md b/docs/architecture/layer-boundaries.md index 638c3c2..f765bc6 100644 --- a/docs/architecture/layer-boundaries.md +++ b/docs/architecture/layer-boundaries.md @@ -64,7 +64,7 @@ cargo xtask architecture audit-boundaries The audit validates direct and transitive workspace dependencies, checks source-level workspace crate imports, enforces targeted source-scan rules, verifies foundational dependency governance in -workspace manifests, and is part of `Governance / validate`. +workspace manifests, and is part of `Governance / governance-gate`. Remaining gaps are still explicit: - non-Rust asset and generated-code dependency analysis is not yet fully enforced diff --git a/docs/process/github-governance-rollout.md b/docs/process/github-governance-rollout.md index 8f48faf..220ca30 100644 --- a/docs/process/github-governance-rollout.md +++ b/docs/process/github-governance-rollout.md @@ -100,7 +100,7 @@ Use the audit output to confirm that the live ruleset still matches `.github/gov Configure the `main` ruleset to require these check names: -- `Governance / validate` +- `Governance / governance-gate` - `CI / pr-gate` - `Security / security-gate` diff --git a/docs/process/github-workflow-migration.md b/docs/process/github-workflow-migration.md index f89e992..2af67d0 100644 --- a/docs/process/github-workflow-migration.md +++ b/docs/process/github-workflow-migration.md @@ -5,13 +5,15 @@ manual-only entrypoints. ## Required Pull Request Checks -- `Governance / validate` +- `Governance / governance-gate` - `CI / pr-gate` - `Security / security-gate` -These checks run automatically on pull requests. `Governance / validate` also emits the generated +These checks run automatically on pull requests. `Governance / governance-gate` also emits the generated process audit artifacts from `cargo xtask github audit-process`, including ADR corpus and -traceability-field validation. +traceability-field validation. `CI / pr-gate`, `Security / security-gate`, and `Governance / governance-gate` +all invoke repo-owned `cargo xtask validate ...` entrypoints so local and GitHub execution stay on +the same command surface. ## Main-Branch Delivery @@ -33,9 +35,23 @@ Run these from the repository root when you want local parity with the enforced ```bash cargo verify-repo -cargo xtask verify profile ui +cargo xtask validate changed +``` + +Install the blocking local hook once per clone: + +```bash +cargo xtask validate install-hooks +``` + +If the change touches `ui/`, also run: + +```bash +cargo verify-ui +cargo xtask validate suite ui-hardening ``` For long or high-risk work, keep the matching `plans/-/` execution artifacts in git as part of the same change set. The audit command writes JSON and Markdown evidence under -`target/process-audit/`. +`target/process-audit/`. Validation commands write Markdown and JSON evidence under +`target/validation/`. diff --git a/docs/process/platform-regression-guardrails.md b/docs/process/platform-regression-guardrails.md index ffc0e7f..a8c236a 100644 --- a/docs/process/platform-regression-guardrails.md +++ b/docs/process/platform-regression-guardrails.md @@ -36,6 +36,8 @@ Safeguard: - contributors must branch from fresh `origin/main`; - contributors must rebase or otherwise refresh from the latest target branch before requesting merge; +- the blocking pre-push hook should reject stale conflict-prone branches locally before they ever + reach GitHub; - GitHub branch protection remains strict on required checks so stale heads cannot merge cleanly by policy drift alone. @@ -83,12 +85,13 @@ Refreshing from the latest target branch and rerunning validation is mandatory w Recommended commands: ```bash -cargo verify-repo +cargo xtask validate install-hooks +cargo xtask validate changed ``` If the change touches `ui/`, also run: ```bash -cargo xtask verify profile ui -cargo xtask ui-hardening +cargo verify-ui +cargo xtask validate suite ui-hardening ``` diff --git a/docs/process/pr-conflict-reduction-playbook.md b/docs/process/pr-conflict-reduction-playbook.md index 7234114..0c797c2 100644 --- a/docs/process/pr-conflict-reduction-playbook.md +++ b/docs/process/pr-conflict-reduction-playbook.md @@ -33,7 +33,7 @@ The same `ui/` shell/runtime files are being edited by multiple refactors and fi ### 4. Mixed source changes and generated outputs -Shell PRs frequently include both hand-authored source changes and regenerated outputs such as `ui/crates/site/src/generated/tailwind.css` and `ui/crates/site/src/generated/tokens.css`. Those generated files amplify conflicts because they are large, derived from shared inputs, and easy to invalidate after a rebase. +Shell PRs historically included both hand-authored source changes and regenerated outputs such as `ui/crates/site/src/generated/tailwind.css` and `ui/crates/site/src/generated/tokens.css`. Those generated files amplified conflicts because they were large, derived from shared inputs, and easy to invalidate after a rebase. The repo now treats those outputs as derived local artifacts and they should not be committed. ### 5. Governance drift between source-of-truth and live settings @@ -54,8 +54,9 @@ Rules: 1. Always fetch before creating the branch. 2. Create the branch from `origin/main`, not from a stale local `main`. 3. Keep the branch focused on one primary issue. -4. Rebase on the current target branch before requesting merge. -5. If `main` moves while the PR is open, rebase again before merge. +4. Install the blocking local hook with `cargo xtask validate install-hooks` so stale or failing pushes are rejected before GitHub. +5. Rebase on the current target branch before requesting merge. +6. If `main` moves while the PR is open, rebase again before merge. For conflict hot spots, rebasing before merge is mandatory: @@ -83,7 +84,7 @@ When work touches the shell/runtime conflict hot spots: 2. Split refactors from behavior fixes when practical. 3. Do not combine wallpaper, taskbar, shell layout, and token regeneration into one PR unless they are inseparable. 4. Regenerate derived assets only after rebasing onto the current target branch. -5. Treat `ui/crates/site/src/generated/*` as derived artifacts that must be refreshed after every rebase that changes shell or token inputs. +5. Treat `ui/crates/site/src/generated/*` and `ui/crates/site/tailwind.config.js` as derived artifacts that must be refreshed locally after shell or token changes, but do not commit them. ## Conflict Resolution Playbook @@ -114,14 +115,14 @@ After the rebase: Recommended validation: ```bash -cargo verify-repo +cargo xtask validate changed ``` If the rebased change touches `ui/`, also run: ```bash -cargo xtask verify profile ui -cargo xtask ui-hardening +cargo verify-ui +cargo xtask validate suite ui-hardening ``` ## Governance And Review Expectations @@ -139,4 +140,4 @@ Operational follow-through: 1. Re-sync repository governance after changing `.github/governance.toml`. 2. Run `cargo xtask github audit-process` regularly to detect drift. -3. Enable merge queue for `main` in GitHub so conflicting or stale PRs are rebased through a single protected integration path. +3. Keep merge queue enabled for `main` in GitHub so conflicting or stale PRs are rebased through a single protected integration path. diff --git a/plans/139-local-first-validation-framework/EXEC_PLAN.md b/plans/139-local-first-validation-framework/EXEC_PLAN.md new file mode 100644 index 0000000..1137895 --- /dev/null +++ b/plans/139-local-first-validation-framework/EXEC_PLAN.md @@ -0,0 +1,35 @@ +# Execution Plan + +## Summary +- Establish a repo-owned local-first validation framework that makes local execution the authoritative path for required checks, aligns GitHub workflows to the same suite-selection and execution logic, blocks stale or failing branches before push, and removes tracked generated UI styling artifacts from the main merge-conflict surface. + +## Task Contract +- Task contract: `plans/139-local-first-validation-framework/task-contract.json` +- GitHub issue: `#139` +- Branch: `infra/139-local-first-validation-framework` + +## Scope Boundaries +- Allowed touchpoints: `xtask/`, `.github/`, `.cargo/`, `ui/`, `docs/`, and `plans/`. +- Non-goals: no unrelated runtime redesign, no container-first workflow replacement, and no bypass of merge-queue or review protections. + +## Implementation Slices +- Add issue-linked execution artifacts and create the new `xtask validate` command family with shared suite selection, base resolution, reporting, and hook installation. +- Introduce repo-owned security validation policy and move workflow-only validation concerns into `xtask`, including local PR validation and CI parity entrypoints. +- Stop tracking generated UI styling artifacts, make them derived outputs, and update build/validation flows to regenerate them automatically. +- Refactor GitHub workflows, Cargo aliases, docs, templates, and governance guidance to call the repo-owned validation surfaces and document the new local-first workflow. + +## Validation Plan +- Run `cargo test -p xtask`. +- Run `cargo xtask validate doctor`. +- Run `cargo xtask validate suite security`. +- Run `cargo xtask validate changed --base origin/main`. +- Run `cargo verify-repo`. +- Run `cargo verify-ui`. +- Run `cargo xtask ui-hardening`. + +## Rollout and Rollback +- Roll forward in one issue-linked branch so the validation framework, workflow parity, and generated-asset governance land together. +- Roll back by reverting the change set as one unit, restoring the prior workflow entrypoints and tracked generated artifacts only if needed to reestablish a coherent validation path. + +## Open Questions +- None. diff --git a/plans/139-local-first-validation-framework/task-contract.json b/plans/139-local-first-validation-framework/task-contract.json new file mode 100644 index 0000000..d8bc5f2 --- /dev/null +++ b/plans/139-local-first-validation-framework/task-contract.json @@ -0,0 +1,78 @@ +{ + "issue_id": 139, + "issue_url": "https://github.com/shortorigin/origin/issues/139", + "branch": "infra/139-local-first-validation-framework", + "primary_architectural_plane": "cross-layer", + "owning_subsystem": "repo-owned validation, GitHub workflow parity, and UI generated-asset governance", + "architectural_references": [ + "AGENTS.md", + "ARCHITECTURE.md", + "docs/architecture/layer-boundaries.md", + "DEVELOPMENT_MODEL.md", + "docs/process/github-workflow-migration.md", + "docs/process/platform-regression-guardrails.md", + "docs/process/pr-conflict-reduction-playbook.md" + ], + "allowed_touchpoints": [ + "xtask/", + ".github/", + ".cargo/", + "ui/", + "docs/", + "plans/" + ], + "non_goals": [ + "Do not redesign domain runtime behavior outside validation and generated-asset flow.", + "Do not replace the native local development model with a container-first workflow.", + "Do not bypass protected-branch, review, or merge-queue policy." + ], + "scope_in": [ + "Add the repo-owned `cargo xtask validate` command family and shared suite selection matrix.", + "Normalize security validation into a local and remote repo-owned suite with explicit exceptions metadata.", + "Install blocking pre-push validation and conflict-hotspot freshness enforcement.", + "Refactor GitHub workflows to call repo-owned validation entrypoints instead of duplicating validation logic in workflow YAML.", + "Stop tracking generated UI styling artifacts and generate them deterministically from repo-owned flows.", + "Update contributor docs, templates, and governance guidance to reflect the new local-first workflow." + ], + "scope_out": [ + "Unrelated product feature work outside validation, workflow parity, and generated asset handling.", + "Containerized-only development flows or environment replacement.", + "Broad runtime architecture changes not required to support validation parity." + ], + "target_paths": [ + "xtask/", + ".github/", + ".cargo/", + "ui/", + "docs/", + "plans/" + ], + "acceptance_criteria": [ + "The repository exposes `cargo xtask validate doctor`, `bootstrap`, `changed`, `suite`, `ci`, and `install-hooks`.", + "Local scoped validation and GitHub CI derive suite selection from the same repo-owned logic.", + "Security validation runs locally and remotely with repo-owned exception metadata that requires owner, linked issue, and expiry.", + "Pre-push hook installation enables blocking local validation before push.", + "Conflict-hotspot branches fail local validation when they are stale relative to their effective base branch.", + "Generated UI CSS and Tailwind outputs are no longer tracked in Git and are reproduced by repo-owned generation.", + "Required GitHub workflows invoke repo-owned validation entrypoints instead of duplicating shell-only validation logic.", + "Contributor docs and templates describe the local-first workflow, merge-queue expectations, and local bypass disclosure." + ], + "validation_commands": [ + "cargo test -p xtask", + "cargo xtask validate doctor", + "cargo xtask validate suite security", + "cargo xtask validate changed --base origin/main", + "cargo verify-repo", + "cargo verify-ui", + "cargo xtask ui-hardening" + ], + "validation_artifacts": [ + "Passing xtask test output", + "Passing validation reports under target/validation", + "Passing repo and UI verification output", + "Updated workflow parity tests" + ], + "rollback_path": "Revert the validation framework, workflow parity updates, and generated-asset tracking changes together so local and remote validation stay aligned.", + "exec_plan_required": true, + "exec_plan_path": "plans/139-local-first-validation-framework/EXEC_PLAN.md" +} diff --git a/ui/README.md b/ui/README.md index 84250ca..fc116ca 100644 --- a/ui/README.md +++ b/ui/README.md @@ -96,6 +96,10 @@ Use the browser/PWA workflow for fast shell iteration and parity checks. Use the validate desktop-only integrations and packaged behavior. Keep all new UI integration behind typed contracts and place presentation-specific models only in `ui/`. +Repo-generated site styling outputs under `ui/crates/site/src/generated/` and +`ui/crates/site/tailwind.config.js` are derived local artifacts. They are regenerated by the +repo-owned UI build flow and should not be committed. + When extending the shell: - add or refine shared UI substrate in `system_ui` before duplicating shell controls; @@ -110,6 +114,7 @@ Run from the repository root: cargo ui-dev cargo ui-build cargo verify-ui +cargo xtask validate suite ui-hardening cargo check -p desktop_runtime cargo check -p site cargo check -p desktop_tauri @@ -118,12 +123,13 @@ cargo rust-trace desktop --dry-run ``` `cargo ui-dev` is the preferred browser/PWA workflow. `cargo ui-build` drives the corresponding -build pipeline. `cargo verify-ui` now exercises the preview toolchain with a real `site_app` wasm -build, Trunk packaging, and a localhost smoke probe so baseline browser regressions are caught -before merge. `cargo rust-trace site` and `cargo rust-trace desktop` apply the repo-owned -backtrace/tracing defaults for the browser and desktop entrypoints. Use the crate-level -`cargo check` commands for focused iteration in the shared runtime, browser entrypoint, and -desktop host. +build pipeline. `cargo verify-ui` is the compatibility alias over the repo-owned `cargo xtask +validate suite ui` flow and exercises the preview toolchain with a real `site_app` wasm build, +Trunk packaging, and a localhost smoke probe so baseline browser regressions are caught before +merge. Run `cargo xtask validate suite ui-hardening` for browser-hardening parity with GitHub. +`cargo rust-trace site` and `cargo rust-trace desktop` apply the repo-owned backtrace/tracing +defaults for the browser and desktop entrypoints. Use the crate-level `cargo check` commands for +focused iteration in the shared runtime, browser entrypoint, and desktop host. ## Integration Patterns All UI-to-platform integration must flow through typed contracts. diff --git a/ui/crates/site/src/generated/tailwind.css b/ui/crates/site/src/generated/tailwind.css deleted file mode 100644 index 3b2ce2e..0000000 --- a/ui/crates/site/src/generated/tailwind.css +++ /dev/null @@ -1,851 +0,0 @@ -/* Generated from ui/crates/system_ui/tokens/tokens.toml */ -*, -*::before, -*::after { - box-sizing: border-box; -} - -:root, -html, -body, -.site-root, -.desktop-shell, -[data-ui-kind="app-shell"], -[data-ui-kind="viewport"] { - min-height: 100%; -} - -html, -body { - margin: 0; - padding: 0; - background: - radial-gradient(circle at top left, color-mix(in srgb, var(--origin-raw-color-accent) 12%, transparent), transparent 32%), - linear-gradient(180deg, var(--origin-raw-color-canvas), var(--origin-raw-color-desktop)); - color: var(--origin-semantic-text-primary); - font-family: var(--origin-raw-type-family-sans); - font-size: var(--origin-raw-type-size-body); - line-height: var(--origin-raw-type-line-body); -} - -body { - overflow: hidden; -} - -button, -input, -textarea, -select { - font: inherit; -} - -a { - color: var(--origin-raw-color-accent-strong); -} - -.site-root { - min-height: 100vh; -} - -.canonical-content { - display: grid; - gap: var(--origin-raw-space-12); - max-width: 760px; - margin: 0 auto; - padding: var(--origin-raw-space-32); -} - -[data-ui-kind="viewport"] { - position: relative; - min-height: 100vh; - width: 100%; -} - -[data-ui-kind="app-shell"] { - position: relative; - min-height: 100vh; - isolation: isolate; -} - -[data-ui-kind="desktop-backdrop"] { - position: absolute; - inset: 0; - z-index: var(--origin-semantic-layer-desktop-backdrop); -} - -[data-ui-kind="desktop-window-layer"] { - position: absolute; - inset: 0; - z-index: var(--origin-semantic-layer-windows); - pointer-events: none; -} - -[data-ui-kind="desktop-window-layer"] > * { - pointer-events: auto; -} - -[data-ui-slot="wallpaper-layer"] { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - z-index: var(--origin-semantic-layer-wallpaper); -} - -[data-ui-primitive="true"], -[data-ui-kind] { - transition: - background-color var(--origin-raw-motion-duration-standard) var(--origin-raw-motion-easing-standard), - border-color var(--origin-raw-motion-duration-standard) var(--origin-raw-motion-easing-standard), - box-shadow var(--origin-raw-motion-duration-standard) var(--origin-raw-motion-easing-standard), - color var(--origin-raw-motion-duration-standard) var(--origin-raw-motion-easing-standard), - opacity var(--origin-raw-motion-duration-standard) var(--origin-raw-motion-easing-standard), - transform var(--origin-raw-motion-duration-fast) var(--origin-raw-motion-easing-standard); -} - -[data-ui-kind="stack"] { - display: flex; - flex-direction: column; -} - -[data-ui-kind="cluster"], -[data-ui-kind="inline"], -[data-ui-kind="taskbar-section"], -[data-ui-kind="window-controls"], -[data-ui-kind="window-title"], -[data-ui-kind="statusbar"], -[data-ui-kind="toolbar"] { - display: flex; -} - -[data-ui-kind="grid"], -[data-ui-kind="desktop-icon-grid"] { - display: grid; -} - -[data-ui-kind="center"] { - display: grid; - place-items: center; -} - -[data-ui-kind="inset"] { - display: block; -} - -[data-ui-kind="layer"] { - position: relative; -} - -[data-ui-gap="none"] { gap: var(--origin-raw-space-0); } -[data-ui-gap="sm"] { gap: var(--origin-raw-space-8); } -[data-ui-gap="md"] { gap: var(--origin-raw-space-12); } -[data-ui-gap="lg"] { gap: var(--origin-raw-space-16); } - -[data-ui-padding="none"] { padding: var(--origin-raw-space-0); } -[data-ui-padding="sm"] { padding: var(--origin-raw-space-8); } -[data-ui-padding="md"] { padding: var(--origin-raw-space-12); } -[data-ui-padding="lg"] { padding: var(--origin-raw-space-16); } - -[data-ui-align="stretch"] { align-items: stretch; } -[data-ui-align="start"] { align-items: flex-start; } -[data-ui-align="center"] { align-items: center; } -[data-ui-align="end"] { align-items: flex-end; } - -[data-ui-justify="start"] { justify-content: flex-start; } -[data-ui-justify="center"] { justify-content: center; } -[data-ui-justify="between"] { justify-content: space-between; } -[data-ui-justify="end"] { justify-content: flex-end; } - -[data-ui-kind="text"], -[data-ui-kind="heading"], -[data-ui-kind="window-title"], -[data-ui-kind="statusbar-item"] { - min-width: 0; -} - -[data-ui-variant="body"] { - font-size: var(--origin-raw-type-size-body); - line-height: var(--origin-raw-type-line-body); -} - -[data-ui-variant="label"] { - font-size: var(--origin-raw-type-size-label); - line-height: var(--origin-raw-type-line-tight); - font-weight: var(--origin-raw-type-weight-medium); -} - -[data-ui-variant="caption"] { - font-size: var(--origin-raw-type-size-caption); - line-height: var(--origin-raw-type-line-tight); - letter-spacing: var(--origin-raw-type-tracking-wide); -} - -[data-ui-variant="title"] { - font-size: var(--origin-raw-type-size-title); - line-height: var(--origin-raw-type-line-title); - font-weight: var(--origin-raw-type-weight-semibold); - letter-spacing: var(--origin-raw-type-tracking-tight); -} - -[data-ui-variant="code"] { - font-family: var(--origin-raw-type-family-mono); - font-size: var(--origin-raw-type-size-code); - line-height: var(--origin-raw-type-line-body); -} - -[data-ui-tone="primary"] { color: var(--origin-semantic-text-primary); } -[data-ui-tone="secondary"] { color: var(--origin-semantic-text-secondary); } -[data-ui-tone="accent"] { color: var(--origin-raw-color-accent-strong); } -[data-ui-tone="success"] { color: var(--origin-raw-color-success); } -[data-ui-tone="warning"] { color: var(--origin-raw-color-warning); } -[data-ui-tone="danger"] { color: var(--origin-raw-color-danger); } - -[data-ui-surface-role="shell"] { - --ui-surface-background: var(--origin-semantic-surface-shell-background); - --ui-surface-border: var(--origin-semantic-surface-shell-border); - --ui-surface-highlight: var(--origin-semantic-surface-shell-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-shell-shadow); - --ui-surface-blur: var(--origin-semantic-surface-shell-blur); -} - -[data-ui-surface-role="taskbar"] { - --ui-surface-background: var(--origin-semantic-surface-taskbar-background); - --ui-surface-border: var(--origin-semantic-surface-taskbar-border); - --ui-surface-highlight: var(--origin-semantic-surface-taskbar-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-taskbar-shadow); - --ui-surface-blur: var(--origin-semantic-surface-taskbar-blur); -} - -[data-ui-surface-role="window-active"] { - --ui-surface-background: var(--origin-semantic-surface-window-active-background); - --ui-surface-border: var(--origin-semantic-surface-window-active-border); - --ui-surface-highlight: var(--origin-semantic-surface-window-active-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-window-active-shadow); - --ui-surface-blur: var(--origin-semantic-surface-window-active-blur); -} - -[data-ui-surface-role="window-inactive"] { - --ui-surface-background: var(--origin-semantic-surface-window-inactive-background); - --ui-surface-border: var(--origin-semantic-surface-window-inactive-border); - --ui-surface-highlight: var(--origin-semantic-surface-window-inactive-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-window-inactive-shadow); - --ui-surface-blur: var(--origin-semantic-surface-window-inactive-blur); -} - -[data-ui-surface-role="menu"] { - --ui-surface-background: var(--origin-semantic-surface-menu-background); - --ui-surface-border: var(--origin-semantic-surface-menu-border); - --ui-surface-highlight: var(--origin-semantic-surface-menu-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-menu-shadow); - --ui-surface-blur: var(--origin-semantic-surface-menu-blur); -} - -[data-ui-surface-role="modal"] { - --ui-surface-background: var(--origin-semantic-surface-modal-background); - --ui-surface-border: var(--origin-semantic-surface-modal-border); - --ui-surface-highlight: var(--origin-semantic-surface-modal-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-modal-shadow); - --ui-surface-blur: var(--origin-semantic-surface-modal-blur); -} - -[data-ui-kind="surface"], -[data-ui-kind="panel"], -[data-ui-kind="list-surface"], -[data-ui-kind="completion-list"], -[data-ui-kind="toolbar"], -[data-ui-kind="statusbar"], -[data-ui-kind="menu-surface"], -[data-ui-kind="disclosure"], -[data-ui-kind="step-flow-step"], -[data-ui-kind="toggle-row"], -[data-ui-kind="taskbar"], -[data-ui-kind="window-frame"], -[data-ui-kind="window-surface"], -[data-ui-kind="launcher-panel"], -[data-ui-kind="side-panel"], -[data-ui-kind="notification-center"] { - position: relative; - border: var(--origin-raw-border-width-1) solid var(--ui-surface-border, var(--origin-semantic-border-standard)); - background: var(--ui-surface-background, var(--origin-semantic-surface-raised-background)); - color: var(--origin-semantic-text-primary); - box-shadow: var(--ui-surface-shadow, var(--origin-semantic-layer-raised-shadow)); - backdrop-filter: blur(var(--ui-surface-blur, var(--origin-semantic-surface-raised-blur))) saturate(150%); - -webkit-backdrop-filter: blur(var(--ui-surface-blur, var(--origin-semantic-surface-raised-blur))) saturate(150%); -} - -[data-ui-kind="surface"]::before, -[data-ui-kind="panel"]::before, -[data-ui-kind="toolbar"]::before, -[data-ui-kind="statusbar"]::before, -[data-ui-kind="taskbar"]::before, -[data-ui-kind="window-frame"]::before, -[data-ui-kind="window-surface"]::before, -[data-ui-kind="menu-surface"]::before, -[data-ui-kind="launcher-panel"]::before, -[data-ui-kind="side-panel"]::before, -[data-ui-kind="notification-center"]::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background: linear-gradient(180deg, var(--ui-surface-highlight, transparent), transparent 48%); - pointer-events: none; -} - -[data-ui-kind="surface"], -[data-ui-kind="panel"], -[data-ui-kind="list-surface"], -[data-ui-kind="completion-list"], -[data-ui-kind="disclosure"], -[data-ui-kind="step-flow-step"], -[data-ui-kind="toggle-row"], -[data-ui-kind="toolbar"], -[data-ui-kind="statusbar"] { - border-radius: var(--origin-raw-radius-12); -} - -[data-ui-kind="surface"], -[data-ui-kind="panel"], -[data-ui-kind="list-surface"] { - --ui-surface-background: var(--origin-semantic-surface-embedded-background); - --ui-surface-border: var(--origin-semantic-surface-embedded-border); - --ui-surface-highlight: var(--origin-semantic-surface-embedded-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-embedded-shadow); - --ui-surface-blur: var(--origin-semantic-surface-embedded-blur); -} - -[data-ui-kind="panel"], -[data-ui-kind="list-surface"], -[data-ui-kind="completion-list"], -[data-ui-kind="toolbar"], -[data-ui-kind="statusbar"], -[data-ui-kind="disclosure"], -[data-ui-kind="step-flow-step"], -[data-ui-kind="toggle-row"] { - --ui-surface-background: var(--origin-semantic-surface-raised-background); - --ui-surface-border: var(--origin-semantic-surface-raised-border); - --ui-surface-highlight: var(--origin-semantic-surface-raised-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-raised-shadow); - --ui-surface-blur: var(--origin-semantic-surface-raised-blur); -} - -[data-ui-kind="button"], -[data-ui-kind="icon-button"], -[data-ui-kind="desktop-icon-button"] { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--origin-raw-space-8); - border: var(--origin-raw-border-width-1) solid var(--origin-semantic-border-standard); - border-radius: var(--origin-raw-radius-12); - padding: 0 var(--origin-raw-space-12); - background: var(--origin-semantic-control-neutral-background); - color: var(--origin-semantic-text-primary); - min-height: 36px; - cursor: pointer; - backdrop-filter: blur(var(--origin-raw-blur-embedded)) saturate(145%); - -webkit-backdrop-filter: blur(var(--origin-raw-blur-embedded)) saturate(145%); -} - -[data-ui-control-tone="accent"], -[data-ui-variant="primary"], -[data-ui-variant="accent"] { - background: var(--origin-semantic-control-accent-background); - border-color: var(--origin-semantic-control-accent-border); -} - -[data-ui-control-tone="danger"], -[data-ui-variant="danger"] { - background: var(--origin-semantic-control-danger-background); - border-color: var(--origin-semantic-control-danger-border); -} - -[data-ui-variant="quiet"] { - background: transparent; -} - -[data-ui-size="sm"] { min-height: 32px; padding: 0 var(--origin-raw-space-8); } -[data-ui-size="md"] { min-height: 36px; padding: 0 var(--origin-raw-space-12); } -[data-ui-size="lg"] { min-height: 40px; padding: 0 var(--origin-raw-space-16); } - -[data-ui-shape="pill"] { border-radius: var(--origin-raw-radius-round); } -[data-ui-shape="circle"] { - border-radius: var(--origin-raw-radius-round); - width: 36px; - min-width: 36px; - padding: 0; -} - -[data-ui-kind="button"]:hover, -[data-ui-kind="icon-button"]:hover, -[data-ui-kind="desktop-icon-button"]:hover, -[data-ui-kind="menu-item"]:hover, -[data-ui-kind="tray-button"]:hover, -[data-ui-kind="taskbar-button"]:hover { - background: var(--origin-semantic-state-hover-surface); -} - -[data-ui-kind="button"]:active, -[data-ui-kind="icon-button"]:active, -[data-ui-kind="desktop-icon-button"]:active { - background: var(--origin-semantic-state-active-surface); - transform: translateY(1px); -} - -[data-ui-kind="button"][data-ui-selected="true"], -[data-ui-kind="button"][data-ui-pressed="true"], -[data-ui-kind="desktop-icon-button"][data-ui-selected="true"] { - background: var(--origin-semantic-state-selected-surface); - border-color: var(--origin-semantic-border-selected); -} - -[data-ui-kind="button"][disabled], -[data-ui-kind="button"][data-ui-disabled="true"], -[data-ui-kind="icon-button"][disabled], -[data-ui-kind="text-field"][disabled] { - cursor: default; - opacity: var(--origin-semantic-state-disabled-content); -} - -[data-ui-kind="button"]:focus-visible, -[data-ui-kind="icon-button"]:focus-visible, -[data-ui-kind="text-field"]:focus-visible, -[data-ui-kind="desktop-icon-button"]:focus-visible { - outline: none; - box-shadow: 0 0 0 var(--origin-raw-border-focus-ring-width) var(--origin-semantic-state-focus-ring); - border-color: var(--origin-semantic-border-focus); -} - -[data-ui-kind="text-field"] { - width: 100%; - min-height: 36px; - border: var(--origin-raw-border-width-1) solid var(--origin-semantic-border-standard); - border-radius: var(--origin-raw-radius-12); - background: var(--origin-semantic-control-neutral-background); - color: var(--origin-semantic-text-primary); - padding: 0 var(--origin-raw-space-12); -} - -[data-ui-kind="checkbox"] { - width: 18px; - height: 18px; - accent-color: var(--origin-raw-color-accent); -} - -[data-ui-kind="desktop-icon-grid"] { - position: absolute; - top: var(--origin-semantic-shell-desktop-padding); - left: var(--origin-semantic-shell-desktop-padding); - grid-template-columns: repeat(auto-fill, minmax(var(--origin-semantic-shell-desktop-icon-tile-size), var(--origin-semantic-shell-desktop-icon-tile-size))); - gap: var(--origin-raw-space-12); - align-content: start; -} - -[data-ui-kind="desktop-icon-button"] { - min-height: var(--origin-semantic-shell-desktop-icon-tile-size); - width: var(--origin-semantic-shell-desktop-icon-tile-size); - flex-direction: column; - justify-content: flex-start; - padding: var(--origin-raw-space-8); - background: rgba(255, 255, 255, 0.14); - border-radius: var(--origin-raw-radius-16); -} - -[data-ui-kind="taskbar"] { - position: absolute; - left: var(--origin-semantic-shell-desktop-padding); - right: var(--origin-semantic-shell-desktop-padding); - bottom: var(--origin-semantic-shell-desktop-padding); - min-height: var(--origin-semantic-shell-taskbar-height); - padding: var(--origin-semantic-shell-taskbar-padding-block) var(--origin-semantic-shell-taskbar-padding-inline); - border-radius: var(--origin-raw-radius-round); - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: var(--origin-semantic-shell-taskbar-section-gap); - z-index: var(--origin-semantic-layer-taskbar); -} - -[data-ui-slot="taskbar-overlay"] { - position: absolute; - left: 50%; - bottom: var(--origin-semantic-shell-dock-floating-offset); - z-index: var(--origin-semantic-layer-taskbar); - transform: translateX(-50%); - pointer-events: none; -} - -[data-ui-kind="dock"] { - display: inline-flex; - align-items: center; - gap: var(--origin-semantic-shell-dock-spacing); - margin: 0; - padding: var(--origin-semantic-shell-dock-padding); - min-height: var(--origin-semantic-shell-dock-height); - width: max-content; - max-width: calc(100vw - 20px); - border: var(--origin-raw-border-width-1) solid color-mix(in srgb, var(--origin-semantic-surface-taskbar-border) 72%, transparent); - border-radius: var(--origin-raw-radius-round); - background: color-mix(in srgb, var(--origin-semantic-surface-taskbar-background) 88%, transparent); - box-shadow: var(--origin-semantic-layer-floating-shadow); - backdrop-filter: blur(var(--origin-semantic-surface-taskbar-blur)) saturate(130%); - -webkit-backdrop-filter: blur(var(--origin-semantic-surface-taskbar-blur)) saturate(130%); - pointer-events: auto; -} - -[data-ui-kind="taskbar-section"] { - min-width: 0; - align-items: center; - gap: var(--origin-semantic-shell-taskbar-item-gap); -} - -[data-ui-kind="dock-section"] { - min-width: 0; - align-items: center; - gap: var(--origin-semantic-shell-dock-spacing); -} - -[data-ui-kind="taskbar-section"][data-ui-slot="center"] { - justify-content: center; -} - -[data-ui-kind="taskbar-section"][data-ui-slot="right"] { - justify-content: flex-end; -} - -[data-ui-kind="dock-section"][data-ui-slot="running"] { - flex: 1 1 auto; - justify-content: flex-start; -} - -[data-ui-kind="dock-section"][data-ui-slot="left"], -[data-ui-kind="dock-section"][data-ui-slot="right"] { - flex: 0 0 auto; -} - -[data-ui-kind="dock-section"][data-ui-slot="right"] { - justify-content: flex-end; -} - -[data-ui-slot="taskbar-button"], -[data-ui-slot="taskbar-overflow-button"], -[data-ui-slot="tray-button"], -[data-ui-slot="clock-button"] { - min-height: var(--origin-semantic-shell-taskbar-button-height); - border-radius: var(--origin-raw-radius-round); - padding: 0 var(--origin-raw-space-8); -} - -[data-ui-slot="taskbar-button"] { - min-width: var(--origin-semantic-shell-taskbar-button-height); -} - -[data-ui-slot="clock-button"] { - min-width: var(--origin-semantic-shell-taskbar-clock-min-width); - justify-content: flex-end; -} - -[data-ui-slot="dock-button"], -[data-ui-slot="dock-launcher-button"], -[data-ui-slot="dock-overflow-button"] { - width: var(--origin-semantic-shell-dock-button-size); - min-width: var(--origin-semantic-shell-dock-button-size); - min-height: var(--origin-semantic-shell-dock-button-size); - padding: 0; - border-radius: var(--origin-raw-radius-round); -} - -[data-ui-slot="dock-clock"] { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: var(--origin-semantic-shell-taskbar-clock-min-width); - padding: 0 var(--origin-raw-space-8); - font-size: var(--origin-raw-type-size-label); - font-weight: var(--origin-raw-type-weight-semibold); - letter-spacing: var(--origin-raw-type-tracking-wide); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -[data-ui-kind="tray-list"] { - display: inline-flex; - align-items: center; - gap: var(--origin-raw-space-4); -} - -[data-ui-kind="window-frame"], -[data-ui-kind="window-surface"] { - position: absolute; - min-width: var(--origin-semantic-shell-window-min-width); - min-height: var(--origin-semantic-shell-window-min-height); - border-radius: var(--origin-raw-radius-16); - overflow: hidden; - z-index: var(--origin-semantic-layer-windows); -} - -[data-ui-kind="window-frame"][data-ui-focused="true"], -[data-ui-kind="window-surface"][data-ui-focused="true"] { - --ui-surface-background: var(--origin-semantic-surface-window-active-background); - --ui-surface-border: var(--origin-semantic-surface-window-active-border); - --ui-surface-highlight: var(--origin-semantic-surface-window-active-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-window-active-shadow); - --ui-surface-blur: var(--origin-semantic-surface-window-active-blur); -} - -[data-ui-kind="window-frame"][data-ui-focused="false"], -[data-ui-kind="window-surface"][data-ui-focused="false"] { - --ui-surface-background: var(--origin-semantic-surface-window-inactive-background); - --ui-surface-border: var(--origin-semantic-surface-window-inactive-border); - --ui-surface-highlight: var(--origin-semantic-surface-window-inactive-highlight); - --ui-surface-shadow: var(--origin-semantic-surface-window-inactive-shadow); - --ui-surface-blur: var(--origin-semantic-surface-window-inactive-blur); -} - -[data-ui-kind="window-frame"][data-ui-maximized="true"] { - border-radius: var(--origin-raw-radius-8); -} - -[data-ui-kind="window-titlebar"], -[data-ui-kind="titlebar-region"] { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--origin-raw-space-12); - min-height: var(--origin-semantic-shell-titlebar-height); - padding: 0 var(--origin-raw-space-12); - border-bottom: var(--origin-raw-border-width-1) solid color-mix(in srgb, var(--ui-surface-border, var(--origin-semantic-border-standard)) 80%, transparent); -} - -[data-ui-kind="window-title"] { - align-items: center; - gap: var(--origin-raw-space-8); - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-size: var(--origin-raw-type-size-label); - font-weight: var(--origin-raw-type-weight-medium); -} - -[data-ui-kind="window-controls"] { - align-items: center; - gap: var(--origin-raw-space-4); -} - -[data-ui-slot="window-control"] { - width: var(--origin-semantic-shell-titlebar-control-size); - min-width: var(--origin-semantic-shell-titlebar-control-size); - min-height: var(--origin-semantic-shell-titlebar-control-size); - padding: 0; - border-radius: var(--origin-raw-radius-round); -} - -[data-ui-kind="window-body"] { - height: calc(100% - var(--origin-semantic-shell-titlebar-height)); - padding: var(--origin-semantic-shell-window-content-padding); - overflow: auto; -} - -[data-ui-kind="resize-handle"], -[data-ui-kind="resize-handle-region"] { - position: absolute; - z-index: 3; -} - -[data-ui-slot="edge-n"] { - top: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - left: var(--origin-semantic-shell-resize-handle-corner); - right: var(--origin-semantic-shell-resize-handle-corner); - height: calc(var(--origin-semantic-shell-resize-handle-edge) + var(--origin-semantic-shell-resize-handle-hit-outset)); - cursor: ns-resize; -} - -[data-ui-slot="edge-s"] { - bottom: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - left: var(--origin-semantic-shell-resize-handle-corner); - right: var(--origin-semantic-shell-resize-handle-corner); - height: calc(var(--origin-semantic-shell-resize-handle-edge) + var(--origin-semantic-shell-resize-handle-hit-outset)); - cursor: ns-resize; -} - -[data-ui-slot="edge-e"] { - top: var(--origin-semantic-shell-resize-handle-corner); - bottom: var(--origin-semantic-shell-resize-handle-corner); - right: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - width: calc(var(--origin-semantic-shell-resize-handle-edge) + var(--origin-semantic-shell-resize-handle-hit-outset)); - cursor: ew-resize; -} - -[data-ui-slot="edge-w"] { - top: var(--origin-semantic-shell-resize-handle-corner); - bottom: var(--origin-semantic-shell-resize-handle-corner); - left: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - width: calc(var(--origin-semantic-shell-resize-handle-edge) + var(--origin-semantic-shell-resize-handle-hit-outset)); - cursor: ew-resize; -} - -[data-ui-slot="edge-ne"], -[data-ui-slot="edge-nw"], -[data-ui-slot="edge-se"], -[data-ui-slot="edge-sw"] { - width: calc(var(--origin-semantic-shell-resize-handle-corner) + var(--origin-semantic-shell-resize-handle-hit-outset)); - height: calc(var(--origin-semantic-shell-resize-handle-corner) + var(--origin-semantic-shell-resize-handle-hit-outset)); -} - -[data-ui-slot="edge-ne"] { - top: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - right: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - cursor: nesw-resize; -} - -[data-ui-slot="edge-nw"] { - top: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - left: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - cursor: nwse-resize; -} - -[data-ui-slot="edge-se"] { - bottom: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - right: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - cursor: nwse-resize; -} - -[data-ui-slot="edge-sw"] { - bottom: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - left: calc(var(--origin-semantic-shell-resize-handle-hit-outset) * -1); - cursor: nesw-resize; -} - -[data-ui-kind="menu-surface"], -[data-ui-kind="launcher-panel"], -[data-ui-kind="side-panel"], -[data-ui-kind="notification-center"] { - border-radius: var(--origin-raw-radius-16); -} - -[data-ui-kind="menu-surface"] { - width: min(var(--origin-semantic-shell-menu-width), calc(100vw - 24px)); - padding: var(--origin-raw-space-8); - display: grid; - gap: var(--origin-raw-space-4); - z-index: var(--origin-semantic-layer-menus); -} - -[data-ui-slot="menu-item"] { - width: 100%; - justify-content: flex-start; - min-height: 34px; -} - -[data-ui-kind="menu-separator"] { - height: var(--origin-raw-border-width-1); - margin: var(--origin-raw-space-4) 0; - background: color-mix(in srgb, var(--origin-semantic-border-standard) 78%, transparent); -} - -[data-ui-kind="launcher-panel"], -[data-ui-kind="side-panel"], -[data-ui-kind="notification-center"] { - width: min(var(--origin-semantic-shell-panel-width), calc(100vw - 24px)); - padding: var(--origin-raw-space-16); - display: grid; - gap: var(--origin-raw-space-12); - z-index: var(--origin-semantic-layer-menus); -} - -[data-ui-kind="notification-center"] { - width: min(var(--origin-semantic-shell-notification-width), calc(100vw - 24px)); -} - -[data-ui-kind="step-flow"] { - display: grid; - gap: var(--origin-raw-space-12); -} - -[data-ui-kind="step-flow-header"] { - display: grid; - gap: var(--origin-raw-space-4); -} - -[data-ui-kind="step-flow-step"], -[data-ui-kind="disclosure"], -[data-ui-kind="toggle-row"] { - padding: var(--origin-raw-space-16); -} - -[data-ui-kind="step-flow-actions"] { - display: flex; - gap: var(--origin-raw-space-8); - margin-top: var(--origin-raw-space-12); -} - -[data-ui-kind="toggle-row"] { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--origin-raw-space-12); -} - -[data-ui-kind="statusbar"] { - justify-content: space-between; - gap: var(--origin-raw-space-12); - padding: var(--origin-raw-space-8) var(--origin-raw-space-12); -} - -[data-ui-kind="statusbar-item"] { - font-size: var(--origin-raw-type-size-caption); - color: var(--origin-semantic-text-secondary); -} - -[data-ui-kind="toolbar"] { - align-items: center; - flex-wrap: wrap; - gap: var(--origin-raw-space-8); - padding: var(--origin-raw-space-8); -} - -@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - [data-ui-kind="surface"], - [data-ui-kind="panel"], - [data-ui-kind="list-surface"], - [data-ui-kind="completion-list"], - [data-ui-kind="toolbar"], - [data-ui-kind="statusbar"], - [data-ui-kind="menu-surface"], - [data-ui-kind="taskbar"], - [data-ui-kind="dock"], - [data-ui-kind="window-frame"], - [data-ui-kind="window-surface"], - [data-ui-kind="launcher-panel"], - [data-ui-kind="side-panel"], - [data-ui-kind="notification-center"] { - backdrop-filter: none; - -webkit-backdrop-filter: none; - } -} - -@media (max-width: 960px) { - [data-ui-kind="taskbar"] { - grid-template-columns: auto minmax(0, 1fr); - } - - [data-ui-kind="taskbar-section"][data-ui-slot="center"] { - justify-content: flex-start; - } -} - -@media (max-width: 720px) { - [data-ui-kind="taskbar"] { - left: var(--origin-raw-space-8); - right: var(--origin-raw-space-8); - bottom: var(--origin-raw-space-8); - } - - [data-ui-kind="window-frame"], - [data-ui-kind="window-surface"] { - min-width: min(var(--origin-semantic-shell-window-min-width), calc(100vw - 16px)); - } -} diff --git a/ui/crates/site/src/generated/tokens.css b/ui/crates/site/src/generated/tokens.css deleted file mode 100644 index 273df2e..0000000 --- a/ui/crates/site/src/generated/tokens.css +++ /dev/null @@ -1,248 +0,0 @@ -/* Generated from ui/crates/system_ui/tokens/tokens.toml */ -:root { - --origin-raw-color-accent: #2f7cf6; - --origin-raw-color-accent-strong: #195fd8; - --origin-raw-color-border-soft: rgba(130, 156, 196, 0.24); - --origin-raw-color-border-strong: rgba(111, 137, 181, 0.38); - --origin-raw-color-canvas: #eef3fb; - --origin-raw-color-danger: #cf5467; - --origin-raw-color-desktop: #dde6f4; - --origin-raw-color-disabled: rgba(228, 236, 247, 0.56); - --origin-raw-color-disabled-content: 0.48; - --origin-raw-color-focus: #77a7ff; - --origin-raw-color-highlight: rgba(255, 255, 255, 0.66); - --origin-raw-color-highlight-strong: rgba(255, 255, 255, 0.82); - --origin-raw-color-neutral-0: rgba(255, 255, 255, 0.92); - --origin-raw-color-neutral-1: rgba(255, 255, 255, 0.8); - --origin-raw-color-neutral-2: rgba(245, 248, 255, 0.72); - --origin-raw-color-neutral-3: rgba(225, 234, 248, 0.62); - --origin-raw-color-neutral-4: rgba(203, 217, 237, 0.48); - --origin-raw-color-selected: rgba(64, 122, 255, 0.18); - --origin-raw-color-selected-strong: rgba(64, 122, 255, 0.42); - --origin-raw-color-shadow: rgba(49, 76, 122, 0.18); - --origin-raw-color-shadow-strong: rgba(40, 63, 101, 0.24); - --origin-raw-color-success: #2f9d66; - --origin-raw-color-text-inverse: #f7fbff; - --origin-raw-color-text-muted: #74839b; - --origin-raw-color-text-primary: #152236; - --origin-raw-color-text-secondary: #51617a; - --origin-raw-color-warning: #c88d1f; - --origin-raw-space-0: 0; - --origin-raw-space-12: 12px; - --origin-raw-space-16: 16px; - --origin-raw-space-2: 2px; - --origin-raw-space-20: 20px; - --origin-raw-space-24: 24px; - --origin-raw-space-28: 28px; - --origin-raw-space-32: 32px; - --origin-raw-space-4: 4px; - --origin-raw-space-40: 40px; - --origin-raw-space-48: 48px; - --origin-raw-space-8: 8px; - --origin-raw-type-family-mono: "IBM Plex Mono", "SFMono-Regular", monospace; - --origin-raw-type-family-sans: "Noto Sans", "Source Sans 3", "Segoe UI", sans-serif; - --origin-raw-type-line-body: 1.45; - --origin-raw-type-line-tight: 1.2; - --origin-raw-type-line-title: 1.25; - --origin-raw-type-size-body: 14px; - --origin-raw-type-size-caption: 12px; - --origin-raw-type-size-code: 13px; - --origin-raw-type-size-label: 13px; - --origin-raw-type-size-title: 16px; - --origin-raw-type-tracking-caps: 0.08em; - --origin-raw-type-tracking-normal: 0; - --origin-raw-type-tracking-tight: -0.02em; - --origin-raw-type-tracking-wide: 0.02em; - --origin-raw-type-weight-bold: 700; - --origin-raw-type-weight-medium: 500; - --origin-raw-type-weight-regular: 400; - --origin-raw-type-weight-semibold: 600; - --origin-raw-blur-embedded: 12px; - --origin-raw-blur-fallback: 0px; - --origin-raw-blur-floating: 24px; - --origin-raw-blur-modal: 30px; - --origin-raw-blur-raised: 18px; - --origin-raw-radius-12: 12px; - --origin-raw-radius-16: 16px; - --origin-raw-radius-8: 8px; - --origin-raw-radius-round: 999px; - --origin-raw-motion-duration-fast: 120ms; - --origin-raw-motion-duration-slow: 260ms; - --origin-raw-motion-duration-standard: 180ms; - --origin-raw-motion-easing-emphasized: cubic-bezier(0.18, 0.92, 0.22, 1); - --origin-raw-motion-easing-standard: cubic-bezier(0.22, 0.88, 0.28, 1); - --origin-raw-border-focus-ring-width: 3px; - --origin-raw-border-width-1: 1px; - --origin-raw-border-width-2: 2px; - --origin-semantic-surface-embedded-background: rgba(255, 255, 255, 0.58); - --origin-semantic-surface-embedded-blur: var(--origin-raw-blur-embedded); - --origin-semantic-surface-embedded-border: var(--origin-raw-color-border-soft); - --origin-semantic-surface-embedded-highlight: var(--origin-raw-color-highlight); - --origin-semantic-surface-embedded-shadow: 0 10px 20px var(--origin-raw-color-shadow); - --origin-semantic-surface-menu-background: rgba(252, 253, 255, 0.84); - --origin-semantic-surface-menu-blur: var(--origin-raw-blur-floating); - --origin-semantic-surface-menu-border: var(--origin-raw-color-border-strong); - --origin-semantic-surface-menu-highlight: var(--origin-raw-color-highlight-strong); - --origin-semantic-surface-menu-shadow: 0 18px 40px var(--origin-raw-color-shadow-strong); - --origin-semantic-surface-modal-background: rgba(252, 253, 255, 0.9); - --origin-semantic-surface-modal-blur: var(--origin-raw-blur-modal); - --origin-semantic-surface-modal-border: var(--origin-raw-color-border-strong); - --origin-semantic-surface-modal-highlight: var(--origin-raw-color-highlight-strong); - --origin-semantic-surface-modal-shadow: 0 24px 52px var(--origin-raw-color-shadow-strong); - --origin-semantic-surface-raised-background: rgba(255, 255, 255, 0.68); - --origin-semantic-surface-raised-blur: var(--origin-raw-blur-raised); - --origin-semantic-surface-raised-border: var(--origin-raw-color-border-soft); - --origin-semantic-surface-raised-highlight: var(--origin-raw-color-highlight); - --origin-semantic-surface-raised-shadow: 0 14px 28px var(--origin-raw-color-shadow); - --origin-semantic-surface-shell-background: rgba(255, 255, 255, 0.26); - --origin-semantic-surface-shell-blur: var(--origin-raw-blur-embedded); - --origin-semantic-surface-shell-border: rgba(255, 255, 255, 0.16); - --origin-semantic-surface-shell-highlight: rgba(255, 255, 255, 0.18); - --origin-semantic-surface-shell-shadow: none; - --origin-semantic-surface-taskbar-background: rgba(255, 255, 255, 0.54); - --origin-semantic-surface-taskbar-blur: var(--origin-raw-blur-embedded); - --origin-semantic-surface-taskbar-border: var(--origin-raw-color-border-soft); - --origin-semantic-surface-taskbar-highlight: var(--origin-raw-color-highlight); - --origin-semantic-surface-taskbar-shadow: 0 12px 28px var(--origin-raw-color-shadow); - --origin-semantic-surface-window-active-background: rgba(255, 255, 255, 0.78); - --origin-semantic-surface-window-active-blur: var(--origin-raw-blur-floating); - --origin-semantic-surface-window-active-border: var(--origin-raw-color-border-strong); - --origin-semantic-surface-window-active-highlight: var(--origin-raw-color-highlight-strong); - --origin-semantic-surface-window-active-shadow: 0 24px 48px var(--origin-raw-color-shadow-strong); - --origin-semantic-surface-window-background: rgba(255, 255, 255, 0.7); - --origin-semantic-surface-window-blur: var(--origin-raw-blur-raised); - --origin-semantic-surface-window-border: var(--origin-raw-color-border-soft); - --origin-semantic-surface-window-highlight: var(--origin-raw-color-highlight); - --origin-semantic-surface-window-inactive-background: rgba(247, 250, 255, 0.64); - --origin-semantic-surface-window-inactive-blur: var(--origin-raw-blur-raised); - --origin-semantic-surface-window-inactive-border: var(--origin-raw-color-border-soft); - --origin-semantic-surface-window-inactive-highlight: var(--origin-raw-color-highlight); - --origin-semantic-surface-window-inactive-shadow: 0 16px 34px var(--origin-raw-color-shadow); - --origin-semantic-surface-window-shadow: 0 18px 40px var(--origin-raw-color-shadow); - --origin-semantic-control-accent-background: rgba(47, 124, 246, 0.18); - --origin-semantic-control-accent-border: rgba(47, 124, 246, 0.38); - --origin-semantic-control-accent-highlight: rgba(255, 255, 255, 0.22); - --origin-semantic-control-danger-background: rgba(207, 84, 103, 0.18); - --origin-semantic-control-danger-border: rgba(207, 84, 103, 0.38); - --origin-semantic-control-danger-highlight: rgba(255, 255, 255, 0.18); - --origin-semantic-control-neutral-background: rgba(255, 255, 255, 0.52); - --origin-semantic-control-neutral-border: var(--origin-raw-color-border-soft); - --origin-semantic-control-neutral-highlight: var(--origin-raw-color-highlight); - --origin-semantic-text-inverse: var(--origin-raw-color-text-inverse); - --origin-semantic-text-muted: var(--origin-raw-color-text-muted); - --origin-semantic-text-primary: var(--origin-raw-color-text-primary); - --origin-semantic-text-secondary: var(--origin-raw-color-text-secondary); - --origin-semantic-border-focus: var(--origin-raw-color-focus); - --origin-semantic-border-selected: var(--origin-raw-color-selected-strong); - --origin-semantic-border-standard: var(--origin-raw-color-border-soft); - --origin-semantic-state-active-surface: rgba(222, 231, 244, 0.84); - --origin-semantic-state-disabled-content: var(--origin-raw-color-disabled-content); - --origin-semantic-state-disabled-surface: var(--origin-raw-color-disabled); - --origin-semantic-state-focus-ring: rgba(119, 167, 255, 0.28); - --origin-semantic-state-hover-surface: rgba(255, 255, 255, 0.74); - --origin-semantic-state-selected-surface: var(--origin-raw-color-selected); - --origin-semantic-shell-desktop-icon-tile-size: 80px; - --origin-semantic-shell-desktop-padding: 12px; - --origin-semantic-shell-dock-button-size: 40px; - --origin-semantic-shell-dock-floating-offset: 10px; - --origin-semantic-shell-dock-height: 46px; - --origin-semantic-shell-dock-padding: 6px; - --origin-semantic-shell-dock-spacing: 6px; - --origin-semantic-shell-menu-width: 240px; - --origin-semantic-shell-notification-width: 360px; - --origin-semantic-shell-panel-width: 360px; - --origin-semantic-shell-resize-handle-corner: 12px; - --origin-semantic-shell-resize-handle-edge: 8px; - --origin-semantic-shell-resize-handle-hit-outset: 4px; - --origin-semantic-shell-taskbar-button-height: 32px; - --origin-semantic-shell-taskbar-clock-min-width: 84px; - --origin-semantic-shell-taskbar-height: 44px; - --origin-semantic-shell-taskbar-item-gap: 4px; - --origin-semantic-shell-taskbar-padding-block: 6px; - --origin-semantic-shell-taskbar-padding-inline: 12px; - --origin-semantic-shell-taskbar-section-gap: 8px; - --origin-semantic-shell-titlebar-control-size: 28px; - --origin-semantic-shell-titlebar-height: 38px; - --origin-semantic-shell-window-content-padding: 16px; - --origin-semantic-shell-window-min-height: 240px; - --origin-semantic-shell-window-min-width: 360px; - --origin-semantic-layer-desktop-backdrop: 10; - --origin-semantic-layer-embedded-shadow: 0 10px 20px var(--origin-raw-color-shadow); - --origin-semantic-layer-floating-shadow: 0 20px 40px var(--origin-raw-color-shadow-strong); - --origin-semantic-layer-menus: 60; - --origin-semantic-layer-modal: 80; - --origin-semantic-layer-modal-shadow: 0 26px 52px var(--origin-raw-color-shadow-strong); - --origin-semantic-layer-raised-shadow: 0 14px 28px var(--origin-raw-color-shadow); - --origin-semantic-layer-taskbar: 30; - --origin-semantic-layer-wallpaper: 0; - --origin-semantic-layer-windows: 40; -} - -:root[data-theme="dark"], -.desktop-shell[data-theme="dark"] { - --origin-raw-color-accent: #85b2ff; - --origin-raw-color-accent-strong: #b9d2ff; - --origin-raw-color-border-soft: rgba(216, 229, 250, 0.22); - --origin-raw-color-border-strong: rgba(224, 235, 252, 0.34); - --origin-raw-color-canvas: #0f1624; - --origin-raw-color-danger: #ff8b99; - --origin-raw-color-desktop: #131c2b; - --origin-raw-color-disabled: rgba(32, 41, 58, 0.64); - --origin-raw-color-focus: #9fc1ff; - --origin-raw-color-highlight: rgba(255, 255, 255, 0.14); - --origin-raw-color-highlight-strong: rgba(255, 255, 255, 0.22); - --origin-raw-color-neutral-0: rgba(27, 37, 54, 0.92); - --origin-raw-color-neutral-1: rgba(27, 37, 54, 0.82); - --origin-raw-color-neutral-2: rgba(24, 33, 50, 0.74); - --origin-raw-color-neutral-3: rgba(19, 28, 43, 0.64); - --origin-raw-color-neutral-4: rgba(16, 23, 36, 0.5); - --origin-raw-color-selected: rgba(133, 178, 255, 0.18); - --origin-raw-color-selected-strong: rgba(133, 178, 255, 0.42); - --origin-raw-color-shadow: rgba(0, 0, 0, 0.28); - --origin-raw-color-shadow-strong: rgba(0, 0, 0, 0.36); - --origin-raw-color-success: #69d79d; - --origin-raw-color-text-inverse: #09111f; - --origin-raw-color-text-muted: #8e9db7; - --origin-raw-color-text-primary: #edf4ff; - --origin-raw-color-text-secondary: #c1d1e9; - --origin-raw-color-warning: #efc266; - --origin-semantic-control-accent-background: rgba(133, 178, 255, 0.22); - --origin-semantic-control-danger-background: rgba(255, 139, 153, 0.2); - --origin-semantic-control-neutral-background: rgba(31, 43, 63, 0.74); - --origin-semantic-surface-menu-background: rgba(22, 30, 45, 0.88); - --origin-semantic-surface-modal-background: rgba(20, 28, 42, 0.92); - --origin-semantic-surface-shell-background: rgba(16, 22, 35, 0.28); - --origin-semantic-surface-taskbar-background: rgba(25, 34, 50, 0.58); - --origin-semantic-surface-window-active-background: rgba(27, 37, 54, 0.84); - --origin-semantic-surface-window-background: rgba(25, 34, 50, 0.76); - --origin-semantic-surface-window-inactive-background: rgba(23, 31, 47, 0.7); -} - -:root[data-high-contrast="true"], -.desktop-shell[data-high-contrast="true"] { - --origin-raw-color-canvas: #010101; - --origin-raw-color-desktop: #040608; - --origin-raw-color-text-primary: #ffffff; - --origin-raw-color-text-secondary: #f2f5f9; - --origin-raw-color-text-muted: #dde5ee; - --origin-raw-color-text-inverse: #020305; - --origin-semantic-border-standard: rgba(255, 255, 255, 0.72); - --origin-semantic-border-focus: #ffffff; - --origin-semantic-border-selected: #9ed1ff; - --origin-semantic-surface-taskbar-background: rgba(12, 18, 28, 0.96); - --origin-semantic-surface-window-active-background: rgba(16, 23, 34, 0.98); - --origin-semantic-surface-window-inactive-background: rgba(12, 18, 28, 0.94); - --origin-semantic-surface-menu-background: rgba(18, 24, 35, 0.985); - --origin-semantic-surface-modal-background: rgba(20, 28, 40, 0.992); - --origin-semantic-layer-embedded-shadow: none; - --origin-semantic-layer-raised-shadow: none; - --origin-semantic-layer-floating-shadow: none; - --origin-semantic-layer-modal-shadow: none; -} - -:root[data-reduced-motion="true"], -.desktop-shell[data-reduced-motion="true"] { - --origin-raw-motion-duration-fast: 0ms; - --origin-raw-motion-duration-standard: 0ms; - --origin-raw-motion-duration-slow: 0ms; -} diff --git a/ui/crates/site/tailwind.config.js b/ui/crates/site/tailwind.config.js deleted file mode 100644 index 7184c72..0000000 --- a/ui/crates/site/tailwind.config.js +++ /dev/null @@ -1,96 +0,0 @@ -// Generated from ui/crates/system_ui/tokens/tokens.toml -const plugin = require("tailwindcss/plugin"); - -module.exports = { - content: ["./src/**/*.rs", "./src/**/*.html"], - theme: { - extend: { - colors: { - semantic: { - text: { - primary: "var(--origin-semantic-text-primary)", - secondary: "var(--origin-semantic-text-secondary)", - muted: "var(--origin-semantic-text-muted)", - inverse: "var(--origin-semantic-text-inverse)", - }, - border: { - standard: "var(--origin-semantic-border-standard)", - focus: "var(--origin-semantic-border-focus)", - selected: "var(--origin-semantic-border-selected)", - }, - surface: { - taskbar: "var(--origin-semantic-surface-taskbar-background)", - window: "var(--origin-semantic-surface-window-background)", - windowActive: "var(--origin-semantic-surface-window-active-background)", - menu: "var(--origin-semantic-surface-menu-background)", - modal: "var(--origin-semantic-surface-modal-background)", - }, - control: { - neutral: "var(--origin-semantic-control-neutral-background)", - accent: "var(--origin-semantic-control-accent-background)", - danger: "var(--origin-semantic-control-danger-background)", - }, - state: { - hover: "var(--origin-semantic-state-hover-surface)", - active: "var(--origin-semantic-state-active-surface)", - selected: "var(--origin-semantic-state-selected-surface)", - focusRing: "var(--origin-semantic-state-focus-ring)", - }, - }, - }, - spacing: { - 0: "var(--origin-raw-space-0)", - 2: "var(--origin-raw-space-2)", - 4: "var(--origin-raw-space-4)", - 8: "var(--origin-raw-space-8)", - 12: "var(--origin-raw-space-12)", - 16: "var(--origin-raw-space-16)", - 20: "var(--origin-raw-space-20)", - 24: "var(--origin-raw-space-24)", - 28: "var(--origin-raw-space-28)", - 32: "var(--origin-raw-space-32)", - 40: "var(--origin-raw-space-40)", - 48: "var(--origin-raw-space-48)", - }, - borderRadius: { - shellSm: "var(--origin-raw-radius-8)", - shellMd: "var(--origin-raw-radius-12)", - shellLg: "var(--origin-raw-radius-16)", - round: "var(--origin-raw-radius-round)", - }, - boxShadow: { - embedded: "var(--origin-semantic-layer-embedded-shadow)", - raised: "var(--origin-semantic-layer-raised-shadow)", - floating: "var(--origin-semantic-layer-floating-shadow)", - modal: "var(--origin-semantic-layer-modal-shadow)", - }, - zIndex: { - wallpaper: "var(--origin-semantic-layer-wallpaper)", - desktopBackdrop: "var(--origin-semantic-layer-desktop-backdrop)", - taskbar: "var(--origin-semantic-layer-taskbar)", - windows: "var(--origin-semantic-layer-windows)", - menus: "var(--origin-semantic-layer-menus)", - modal: "var(--origin-semantic-layer-modal)", - }, - transitionDuration: { - fast: "var(--origin-raw-motion-duration-fast)", - DEFAULT: "var(--origin-raw-motion-duration-standard)", - slow: "var(--origin-raw-motion-duration-slow)", - }, - transitionTimingFunction: { - standard: "var(--origin-raw-motion-easing-standard)", - emphasized: "var(--origin-raw-motion-easing-emphasized)", - }, - }, - }, - plugins: [ - plugin(function ({ addUtilities }) { - addUtilities({ - ".shell-focus-ring": { - boxShadow: "0 0 0 var(--origin-raw-border-focus-ring-width) var(--origin-semantic-state-focus-ring)", - }, - }); - }), - ], - corePlugins: { preflight: false }, -}; diff --git a/ui/crates/system_ui/build.rs b/ui/crates/system_ui/build.rs index b866c39..b600649 100644 --- a/ui/crates/system_ui/build.rs +++ b/ui/crates/system_ui/build.rs @@ -1038,6 +1038,7 @@ a { } fn main() { + println!("cargo:rerun-if-env-changed=ORIGIN_FORCE_SYSTEM_UI_GENERATION"); let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("manifest dir")); let token_path = manifest_dir.join("tokens/tokens.toml"); println!("cargo:rerun-if-changed={}", token_path.display()); diff --git a/ui/crates/system_ui/src/origin_tokens/schema.rs b/ui/crates/system_ui/src/origin_tokens/schema.rs index 365c709..8cdf8be 100644 --- a/ui/crates/system_ui/src/origin_tokens/schema.rs +++ b/ui/crates/system_ui/src/origin_tokens/schema.rs @@ -41,6 +41,8 @@ pub struct ThemeTokens { #[cfg(test)] mod tests { use super::TokenFile; + use std::fs; + use std::path::PathBuf; #[test] fn semantic_token_schema_parses_current_token_file() { @@ -60,7 +62,8 @@ mod tests { #[test] fn generated_tailwind_config_exposes_semantic_shell_tokens() { - let raw = include_str!("../../../site/tailwind.config.js"); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../site/tailwind.config.js"); + let raw = fs::read_to_string(&path).expect("generated tailwind config should exist"); assert!(raw.contains("semantic")); assert!(raw.contains("taskbar")); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index a2d73f2..19e754c 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [dependencies] base64.workspace = true +chrono.workspace = true jsonschema.workspace = true lattice-config = { path = "../platform/wasmcloud/lattice-config" } regex.workspace = true diff --git a/xtask/src/common.rs b/xtask/src/common.rs index 15672a8..6a97c7b 100644 --- a/xtask/src/common.rs +++ b/xtask/src/common.rs @@ -1,6 +1,9 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const UI_ASSET_GENERATION_ENV: &str = "ORIGIN_FORCE_SYSTEM_UI_GENERATION"; pub fn absolutize(workspace_root: &Path, value: &str) -> PathBuf { let path = PathBuf::from(value); @@ -18,6 +21,15 @@ pub fn run_command(command: &mut Command) -> Result<(), String> { ensure_success(command, status) } +pub fn stamp_ui_asset_generation(command: &mut Command) { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_string(); + command.env(UI_ASSET_GENERATION_ENV, nonce); +} + pub fn ensure_success(command: &Command, status: ExitStatus) -> Result<(), String> { if status.success() { Ok(()) diff --git a/xtask/src/github.rs b/xtask/src/github.rs index 09fecf8..8cff32a 100644 --- a/xtask/src/github.rs +++ b/xtask/src/github.rs @@ -275,6 +275,7 @@ pub fn run(args: Vec) -> Result<(), String> { match args.split_first() { Some((command, rest)) if command == "sync" => sync(rest), Some((command, rest)) if command == "validate-pr" => validate_pr(rest), + Some((command, rest)) if command == "validate-pr-local" => validate_pr_local(rest), Some((command, rest)) if command == "validate-execution-artifacts" => { validate_execution_artifacts(rest) } @@ -350,6 +351,90 @@ fn validate_pr(args: &[String]) -> Result<(), String> { Ok(()) } +fn validate_pr_local(args: &[String]) -> Result<(), String> { + let mut config_path = PathBuf::from(".github/governance.toml"); + let mut title = None; + let mut body_file = None; + let mut branch = None; + let mut base_ref = None; + let mut index = 0usize; + + while index < args.len() { + match args[index].as_str() { + "--config" => { + let Some(path) = args.get(index + 1) else { + return Err("missing value for --config".to_owned()); + }; + config_path = PathBuf::from(path); + index += 2; + } + "--title" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --title".to_owned()); + }; + title = Some(value.clone()); + index += 2; + } + "--body-file" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --body-file".to_owned()); + }; + body_file = Some(PathBuf::from(value)); + index += 2; + } + "--branch" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --branch".to_owned()); + }; + branch = Some(value.clone()); + index += 2; + } + "--base" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --base".to_owned()); + }; + base_ref = Some(value.clone()); + index += 2; + } + other => return Err(format!("unknown validate-pr-local argument `{other}`")), + } + } + + let config = load_config(&config_path)?; + let workspace_root = workspace_root()?; + let title = title.ok_or_else(|| "missing --title".to_owned())?; + let body_path = body_file.ok_or_else(|| "missing --body-file".to_owned())?; + let body = fs::read_to_string(&body_path) + .map_err(|error| format!("failed to read `{}`: {error}", body_path.display()))?; + let branch = branch.unwrap_or_else(|| current_branch(&workspace_root)); + let base_ref = base_ref.unwrap_or_else(|| current_base_ref(&workspace_root)); + let base_sha = git_output( + &workspace_root, + &["merge-base", &base_ref, "HEAD"], + "resolve local PR merge-base", + )?; + let head_sha = git_output( + &workspace_root, + &["rev-parse", "HEAD"], + "resolve local PR head SHA", + )?; + let event = PullRequestEvent { + title, + body, + branch, + repository: default_repository(&config), + base_sha: Some(base_sha), + head_sha: Some(head_sha), + changed_files: Vec::new(), + }; + validate_pr_event(&config, &event)?; + println!( + "validated local PR governance for branch `{}` with title `{}`", + event.branch, event.title + ); + Ok(()) +} + fn validate_execution_artifacts(args: &[String]) -> Result<(), String> { let mut config_path = PathBuf::from(".github/governance.toml"); let mut issue_id: Option = None; @@ -888,6 +973,7 @@ fn collect_audit_defects( defects.extend(audit_nested_agent_guides()?); defects.extend(audit_docs_indexes()?); defects.extend(audit_governance_workflow_for_architecture_step()?); + defects.extend(audit_validation_workflow_entrypoints()?); defects.extend(audit_adr_corpus(&workspace_root()?)?); Ok(defects) @@ -946,6 +1032,12 @@ fn audit_pr_template(required_sections: &[String]) -> Result, String )); } } + if !raw.contains("git push --no-verify") { + defects.push( + "pull request template must disclose any `git push --no-verify` local-validation bypass" + .to_owned(), + ); + } Ok(defects) } @@ -1005,14 +1097,18 @@ fn audit_governance_workflow_for_architecture_step() -> Result, Stri let raw = fs::read_to_string(workspace_root.join(path)) .map_err(|error| format!("failed to read `{path}`: {error}"))?; let mut defects = Vec::new(); - if !raw.contains("cargo xtask architecture audit-boundaries") { + if !(raw.contains("cargo xtask architecture audit-boundaries") + || raw.contains("cargo xtask validate suite governance")) + { defects.push( - "governance workflow must run `cargo xtask architecture audit-boundaries`".to_owned(), + "governance workflow must run the repo-owned governance validation path".to_owned(), ); } - if !raw.contains("cargo xtask plugin validate-manifests") { + if !(raw.contains("cargo xtask plugin validate-manifests") + || raw.contains("cargo xtask validate suite governance")) + { defects.push( - "governance workflow must run `cargo xtask plugin validate-manifests`".to_owned(), + "governance workflow must validate governed plugin manifests through the repo-owned governance path".to_owned(), ); } if !raw.contains("cargo xtask github validate-pr") { @@ -1024,6 +1120,36 @@ fn audit_governance_workflow_for_architecture_step() -> Result, Stri Ok(defects) } +fn audit_validation_workflow_entrypoints() -> Result, String> { + let workspace_root = workspace_root()?; + let checks = [ + ( + ".github/workflows/ci.yml", + "cargo xtask validate ci", + "CI workflow must use `cargo xtask validate ci` as the repo-owned validation entrypoint", + ), + ( + ".github/workflows/security.yml", + "cargo xtask validate suite security", + "Security workflow must use `cargo xtask validate suite security` as the repo-owned validation entrypoint", + ), + ( + ".github/workflows/governance.yml", + "cargo xtask validate suite governance", + "Governance workflow must use `cargo xtask validate suite governance` as the repo-owned validation entrypoint", + ), + ]; + let mut defects = Vec::new(); + for (path, command, defect) in checks { + let raw = fs::read_to_string(workspace_root.join(path)) + .map_err(|error| format!("failed to read `{path}`: {error}"))?; + if !raw.contains(command) { + defects.push(defect.to_owned()); + } + } + Ok(defects) +} + fn audit_adr_corpus(workspace_root: &Path) -> Result, String> { let root_regex = Regex::new(r"^(?P\d{4})-.*\.md$") .map_err(|error| format!("failed to build root ADR filename regex: {error}"))?; @@ -2778,11 +2904,50 @@ Subcommands: sync Sync repository governance settings from .github/governance.toml validate-execution-artifacts Validate plan/task-contract artifacts for an issue branch validate-pr Validate pull request title, branch, and issue linkage + validate-pr-local Validate local PR title/body content before opening a PR audit-process Audit contributor docs, governance config, and workflow enforcement " .to_owned() } +fn git_output(workspace_root: &Path, args: &[&str], label: &str) -> Result { + let output = Command::new("git") + .current_dir(workspace_root) + .args(args) + .output() + .map_err(|error| format!("failed to {label}: {error}"))?; + if !output.status.success() { + return Err(format!( + "failed to {label}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn current_branch(workspace_root: &Path) -> String { + git_output( + workspace_root, + &["branch", "--show-current"], + "resolve current branch", + ) + .unwrap_or_else(|_| "HEAD".to_owned()) +} + +fn current_base_ref(workspace_root: &Path) -> String { + git_output( + workspace_root, + &[ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], + "resolve current upstream branch", + ) + .unwrap_or_else(|_| "origin/main".to_owned()) +} + #[cfg(test)] mod tests { use super::{ @@ -2813,7 +2978,7 @@ mod tests { assert_eq!( config.repository_defaults.required_status_checks, vec![ - "Governance / validate", + "Governance / governance-gate", "CI / pr-gate", "Security / security-gate", ] @@ -2998,7 +3163,7 @@ mod tests { fn valid_task_contract(issue_id: u64, dir_name: &str, branch: &str) -> String { format!( - "{{\n \"issue_id\": {issue_id},\n \"issue_url\": \"https://github.com/shortorigin/origin/issues/{issue_id}\",\n \"branch\": \"{branch}\",\n \"primary_architectural_plane\": \"cross-layer\",\n \"owning_subsystem\": \"xtask governance\",\n \"architectural_references\": [\"docs/adr/0015-gitops-and-policy-as-code-control-artifacts.md\"],\n \"allowed_touchpoints\": [\"xtask/\", \"plans/\"],\n \"non_goals\": [\"no runtime redesign\"],\n \"scope_in\": [\"validate execution artifacts\"],\n \"scope_out\": [\"unrelated work\"],\n \"target_paths\": [\"xtask/\", \"plans/\"],\n \"acceptance_criteria\": [\"artifacts validate\"],\n \"validation_commands\": [\"cargo xtask verify profile repo\"],\n \"validation_artifacts\": [\"passing xtask output\"],\n \"rollback_path\": \"revert the workflow changes\",\n \"exec_plan_required\": true,\n \"exec_plan_path\": \"plans/{dir_name}/EXEC_PLAN.md\"\n}}\n" + "{{\n \"issue_id\": {issue_id},\n \"issue_url\": \"https://github.com/shortorigin/origin/issues/{issue_id}\",\n \"branch\": \"{branch}\",\n \"primary_architectural_plane\": \"cross-layer\",\n \"owning_subsystem\": \"xtask governance\",\n \"architectural_references\": [\"docs/adr/0015-gitops-and-policy-as-code-control-artifacts.md\"],\n \"allowed_touchpoints\": [\"xtask/\", \"plans/\"],\n \"non_goals\": [\"no runtime redesign\"],\n \"scope_in\": [\"validate execution artifacts\"],\n \"scope_out\": [\"unrelated work\"],\n \"target_paths\": [\"xtask/\", \"plans/\"],\n \"acceptance_criteria\": [\"artifacts validate\"],\n \"validation_commands\": [\"cargo verify-repo\"],\n \"validation_artifacts\": [\"passing xtask output\"],\n \"rollback_path\": \"revert the workflow changes\",\n \"exec_plan_required\": true,\n \"exec_plan_path\": \"plans/{dir_name}/EXEC_PLAN.md\"\n}}\n" ) } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7ea4bc8..c37392f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod github; mod plugin; mod rust; mod ui_hardening; +mod validate; use std::env; use std::fs; @@ -15,7 +16,7 @@ use std::process::{Child, Command}; use std::thread; use std::time::{Duration, Instant}; -use common::{absolutize, run_command, workspace_root}; +use common::{absolutize, run_command, stamp_ui_asset_generation, workspace_root}; use regex::Regex; const UI_PACKAGES: &[&str] = &[ @@ -73,6 +74,7 @@ fn run() -> Result<(), String> { Some("ui") => run_ui(args.collect()), Some("tauri") => run_tauri(args.collect()), Some("components") => run_components(args.collect()), + Some("validate") => validate::run(args.collect()), Some("verify") => run_verify(args.collect()), Some(command) => Err(format!("unknown xtask command `{command}`")), None => Err(help()), @@ -95,6 +97,7 @@ fn run_ui(args: Vec) -> Result<(), String> { command.current_dir(&site_dir); command.arg(trunk_subcommand); command.arg(index); + stamp_ui_asset_generation(&mut command); sanitize_trunk_environment(&mut command); let mut passthrough = passthrough.to_vec(); @@ -122,6 +125,7 @@ fn run_tauri(args: Vec) -> Result<(), String> { let tauri_dir = workspace_root.join("ui/crates/desktop_tauri"); let mut command = Command::new("cargo"); command.current_dir(&tauri_dir); + stamp_ui_asset_generation(&mut command); command.arg("tauri"); command.arg(subcommand); command.args(passthrough); @@ -137,6 +141,7 @@ fn run_components(args: Vec) -> Result<(), String> { let mut command = Command::new("cargo"); command.current_dir(workspace_root); + stamp_ui_asset_generation(&mut command); command.args([ "check", "-p", @@ -181,92 +186,23 @@ fn run_verify(args: Vec) -> Result<(), String> { _ => return Err("expected `verify profile `".to_string()), }; - let workspace_root = workspace_root()?; match profile { "core" | "fast" => { - cargo(&workspace_root, &["fmt", "--all", "--check"])?; - cargo( - &workspace_root, - &workspace_command_with_excludes( - &["clippy", "--workspace", "--all-targets", "--all-features"], - CORE_EXCLUDED_PACKAGES, - &["--", "-D", "warnings"], - ), - )?; - cargo( - &workspace_root, - &workspace_command_with_excludes( - &["test", "--workspace", "--all-targets"], - CORE_EXCLUDED_PACKAGES, - &[], - ), - )?; + validate::run(vec!["suite".to_string(), "core".to_string()])?; } "repo" => { - cargo(&workspace_root, &["fmt", "--all", "--check"])?; - cargo( - &workspace_root, - &workspace_command_with_excludes( - &["clippy", "--workspace", "--all-targets", "--all-features"], - CORE_EXCLUDED_PACKAGES, - &["--", "-D", "warnings"], - ), - )?; - cargo( - &workspace_root, - &workspace_command_with_excludes( - &["test", "--workspace", "--all-targets"], - CORE_EXCLUDED_PACKAGES, - &[], - ), - )?; - cargo( - &workspace_root, - &["xtask", "architecture", "audit-boundaries"], - )?; - cargo(&workspace_root, &["xtask", "plugin", "validate-manifests"])?; - cargo(&workspace_root, &["xtask", "github", "audit-process"])?; + validate::run(vec![ + "suite".to_string(), + "governance".to_string(), + "security".to_string(), + "core".to_string(), + ])?; } "ui" | "ui-ci" => { - cargo( - &workspace_root, - &package_command_with_packages( - &["clippy", "--all-targets", "--all-features"], - UI_PACKAGES, - &["--", "-D", "warnings"], - ), - )?; - cargo( - &workspace_root, - &package_command_with_packages(&["test", "--all-targets"], UI_PACKAGES, &[]), - )?; - verify_ui_browser_manifest_hygiene(&workspace_root)?; - verify_ui_shell_style_hygiene(&workspace_root)?; - run_ui_preview_smoke(&workspace_root)?; - run_ui(vec![ - "build".to_string(), - "--features".to_string(), - "desktop-tauri".to_string(), - "--dist".to_string(), - "target/trunk-ci-dist".to_string(), - ])?; + validate::run(vec!["suite".to_string(), "ui".to_string()])?; } "full" => { - cargo(&workspace_root, &["fmt", "--all", "--check"])?; - cargo( - &workspace_root, - &[ - "clippy", - "--workspace", - "--all-targets", - "--all-features", - "--", - "-D", - "warnings", - ], - )?; - cargo(&workspace_root, &["test", "--workspace", "--all-targets"])?; - verify_ui_shell_style_hygiene(&workspace_root)?; + validate::run(vec!["suite".to_string(), "full".to_string()])?; } other => return Err(format!("unknown verification profile `{other}`")), } @@ -298,6 +234,7 @@ fn run_wasmcloud_doctor(workspace_root: &Path) -> Result<(), String> { fn run_wasmcloud_manifest(workspace_root: &Path, args: &[String]) -> Result<(), String> { let mut command = Command::new("cargo"); command.current_dir(workspace_root); + stamp_ui_asset_generation(&mut command); command.args(["xtask", "delivery", "render-manifest"]); command.args(args); run_command(&mut command) @@ -404,6 +341,7 @@ fn package_command_with_packages( fn cargo(workspace_root: &Path, args: &[&str]) -> Result<(), String> { let mut command = Command::new("cargo"); command.current_dir(workspace_root); + stamp_ui_asset_generation(&mut command); command.args(args); run_command(&mut command) } @@ -431,6 +369,7 @@ fn run_ui_preview_smoke(workspace_root: &Path) -> Result<(), String> { let site_dir = workspace_root.join("ui/crates/site"); let mut command = Command::new("trunk"); command.current_dir(&site_dir); + stamp_ui_asset_generation(&mut command); command.arg("serve"); command.arg("index.html"); command.arg("--no-autoreload"); @@ -727,6 +666,7 @@ Commands: github GitHub governance sync, PR validation, and process auditing plugin Governed plugin manifest validation rust Rust build-cache audit, targeted cleanup, and tracing helpers + validate Local-first validation suites, bootstrap, and hook installation verify Workspace verification profiles, including `repo` for canonical non-UI validation delivery Delivery manifest and component rendering ui-hardening Deterministic UI/browser hardening verification diff --git a/xtask/src/ui_hardening.rs b/xtask/src/ui_hardening.rs index 1d89fcb..eddebfc 100644 --- a/xtask/src/ui_hardening.rs +++ b/xtask/src/ui_hardening.rs @@ -14,6 +14,8 @@ use serde::Deserialize; use serde_json::Value; use sha2::{Digest, Sha256, Sha384}; +use crate::common::stamp_ui_asset_generation; + const BUILD_ROOT: &str = "build/wasm-hardening"; const CARGO_TARGET_DIR_NAME: &str = "cargo-target"; const BUILD_A_NAME: &str = "build-a"; @@ -67,6 +69,7 @@ pub fn run(args: Vec) -> Result<(), String> { .map_err(|error| format!("failed to create `{}`: {error}", base_dir.display()))?; let report = verify(&workspace_root, &base_dir)?; + let hardened = report.contains("- pipeline status: HARDENED"); fs::write(&output_path, &report).map_err(|error| { format!( "failed to write report `{}`: {error}", @@ -74,7 +77,14 @@ pub fn run(args: Vec) -> Result<(), String> { ) })?; println!("{report}"); - Ok(()) + if hardened { + Ok(()) + } else { + Err(format!( + "UI hardening verification failed; see `{}`", + output_path.display() + )) + } } fn parse_output_path(workspace_root: &Path, args: &[String]) -> PathBuf { @@ -689,15 +699,11 @@ fn build_findings( let mut findings = Vec::new(); if !config_audit.workflows.iter().all(|(_path, contents)| { - contents.contains("dtolnay/rust-toolchain@1.91.1") - || (contents.contains("uses: ./.github/actions/setup-build-environment") - && config_audit - .setup_build_environment_action - .contains("dtolnay/rust-toolchain@1.91.1")) + workflow_uses_pinned_rust_toolchain(contents, &config_audit.setup_build_environment_action) }) { findings.push(Finding::fail( "CI still uses a floating Rust toolchain", - "Expected all Rust-installing workflows to pin `dtolnay/rust-toolchain@1.91.1`.", + "Expected all Rust-installing workflows to pin the Rust toolchain directly or to use the repo-owned setup action that resolves the pinned channel from `rust-toolchain.toml`.", "floating toolchain selection can alter browser artifacts across environments", )); } @@ -824,6 +830,21 @@ fn build_findings( findings } +fn workflow_uses_pinned_rust_toolchain( + workflow_contents: &str, + setup_action_contents: &str, +) -> bool { + workflow_contents.contains("dtolnay/rust-toolchain@1.91.1") + || (workflow_contents.contains("uses: ./.github/actions/setup-build-environment") + && setup_action_uses_pinned_rust_toolchain(setup_action_contents)) +} + +fn setup_action_uses_pinned_rust_toolchain(contents: &str) -> bool { + contents.contains("rustup toolchain install") + && contents.contains("rust-toolchain.toml") + && contents.contains("rustup component add") +} + fn build_actions(findings: &[Finding]) -> Vec { findings .iter() @@ -1394,6 +1415,7 @@ fn run_logged_command( fn command(program: &str, args: [&str; N]) -> Command { let mut command = Command::new(program); + stamp_ui_asset_generation(&mut command); command.args(args); command } diff --git a/xtask/src/validate.rs b/xtask/src/validate.rs new file mode 100644 index 0000000..9bf9a91 --- /dev/null +++ b/xtask/src/validate.rs @@ -0,0 +1,1798 @@ +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Instant; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::common::{display_command, run_command, workspace_root}; + +const VALIDATION_CONFIG_PATH: &str = "xtask/validation.toml"; +const SECURITY_EXCEPTIONS_PATH: &str = ".github/security-exceptions.toml"; +const REPORT_DIR: &str = "target/validation"; +const PRE_PUSH_HOOK_PATH: &str = ".githooks/pre-push"; + +#[derive(Debug, Deserialize)] +struct ValidationConfig { + version: u32, + tools: ToolConfig, + selectors: SelectorConfig, + freshness: FreshnessConfig, +} + +#[derive(Debug, Deserialize)] +struct ToolConfig { + node_major: u64, + cargo_audit_version: String, + trunk_version: String, + nomad_image: String, +} + +#[derive(Debug, Deserialize)] +struct SelectorConfig { + shared_root_files: Vec, + shared_root_prefixes: Vec, + core_rust_prefixes: Vec, + ui_prefixes: Vec, + nomad_prefixes: Vec, + nomad_suffixes: Vec, + pulumi_prefixes: Vec, +} + +#[derive(Debug, Deserialize)] +struct FreshnessConfig { + required_prefixes: Vec, +} + +#[derive(Debug, Deserialize)] +struct SecurityExceptions { + version: u32, + exceptions: Vec, +} + +#[derive(Debug, Deserialize)] +struct SecurityException { + ids: Vec, + owner: String, + issue: u64, + expires: String, + reason: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +enum Suite { + Governance, + Security, + Core, + Ui, + UiHardening, + Nomad, + Pulumi, +} + +#[derive(Debug, Serialize)] +struct ValidationReport { + mode: String, + event: Option, + branch: String, + base_ref: Option, + merge_base: Option, + head_ref: String, + changed_files: Vec, + freshness_required: bool, + freshness_ok: bool, + selected_suites: Vec, + suite_results: Vec, +} + +#[derive(Debug, Serialize)] +struct SuiteResult { + suite: String, + status: String, + detail: String, + elapsed_ms: u128, +} + +#[derive(Debug)] +struct ValidationContext { + workspace_root: PathBuf, + config: ValidationConfig, +} + +#[derive(Debug)] +struct ChangedSelection { + branch: String, + base_ref: String, + merge_base: String, + head_ref: String, + changed_files: Vec, + suites: Vec, + freshness_required: bool, + freshness_ok: bool, +} + +pub fn run(args: Vec) -> Result<(), String> { + let (subcommand, rest) = args.split_first().ok_or_else(|| help().to_string())?; + match subcommand.as_str() { + "doctor" => run_doctor(rest), + "bootstrap" => run_bootstrap(rest), + "changed" => run_changed(rest), + "suite" => run_suite_command(rest), + "ci" => run_ci(rest), + "install-hooks" => run_install_hooks(rest), + other => Err(format!("unsupported validate subcommand `{other}`")), + } +} + +fn run_doctor(args: &[String]) -> Result<(), String> { + ensure_no_trailing(args, "validate doctor")?; + let context = load_context()?; + let checks = doctor_checks(&context); + let report = render_doctor_report(&checks); + write_text_report(&context.workspace_root, "doctor.md", &report)?; + println!("{report}"); + + let failures = checks.iter().filter(|check| !check.ok).collect::>(); + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "validation doctor found {} blocking prerequisite issue(s); see `{REPORT_DIR}/doctor.md`", + failures.len() + )) + } +} + +fn run_bootstrap(args: &[String]) -> Result<(), String> { + ensure_no_trailing(args, "validate bootstrap")?; + let context = load_context()?; + + super::ensure_command_available("rustup", &["--version"])?; + run_command( + Command::new("rustup") + .current_dir(&context.workspace_root) + .args(["target", "add", "wasm32-unknown-unknown"]), + )?; + + if !command_available("trunk") { + run_command( + Command::new("cargo") + .current_dir(&context.workspace_root) + .args([ + "install", + "trunk", + "--locked", + "--version", + &context.config.tools.trunk_version, + ]), + )?; + } + + if !cargo_subcommand_available(&context.workspace_root, "audit") { + run_command( + Command::new("cargo") + .current_dir(&context.workspace_root) + .args([ + "install", + "cargo-audit", + "--locked", + "--version", + &context.config.tools.cargo_audit_version, + ]), + )?; + } + + ensure_command( + "node", + &["--version"], + "install Node from `.nvmrc` before bootstrap", + )?; + ensure_command("npm", &["--version"], "install npm before bootstrap")?; + + run_command( + Command::new("npm") + .current_dir(&context.workspace_root) + .args(["ci", "--prefix", "ui/e2e"]), + )?; + run_command(&mut playwright_install_command( + &context.workspace_root, + true, + ))?; + run_command( + Command::new("npm") + .current_dir(&context.workspace_root) + .args(["ci", "--prefix", "infrastructure/pulumi"]), + )?; + + run_doctor(&[])?; + Ok(()) +} + +fn run_changed(args: &[String]) -> Result<(), String> { + let mut base_ref = None; + let mut head_ref = "HEAD".to_string(); + let mut fetch_base = false; + let mut enforce_freshness = true; + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "--base" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --base".to_string()); + }; + base_ref = Some(value.clone()); + index += 2; + } + "--head" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --head".to_string()); + }; + head_ref.clone_from(value); + index += 2; + } + "--fetch-base" => { + fetch_base = true; + index += 1; + } + "--no-freshness" => { + enforce_freshness = false; + index += 1; + } + other => return Err(format!("unknown validate changed argument `{other}`")), + } + } + + let context = load_context()?; + let selection = select_changed_suites( + &context, + base_ref.as_deref(), + &head_ref, + fetch_base, + enforce_freshness, + )?; + let suite_results = if selection.freshness_required && !selection.freshness_ok { + vec![freshness_failure_result(&selection.base_ref)] + } else { + execute_suites(&context, &selection.suites) + }; + let report = ValidationReport { + mode: "changed".to_string(), + event: None, + branch: selection.branch, + base_ref: Some(selection.base_ref), + merge_base: Some(selection.merge_base), + head_ref: selection.head_ref, + changed_files: selection.changed_files, + freshness_required: selection.freshness_required, + freshness_ok: selection.freshness_ok, + selected_suites: selection + .suites + .iter() + .map(|suite| suite.as_str().to_string()) + .collect(), + suite_results, + }; + finish_report(&context.workspace_root, "changed", &report) +} + +fn run_suite_command(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Err("expected `validate suite ...`".to_string()); + } + let context = load_context()?; + let suites = parse_suite_args(args)?; + let branch = current_branch(&context.workspace_root)?; + let head_ref = resolve_revision(&context.workspace_root, "HEAD")?; + let suite_results = execute_suites(&context, &suites); + let report = ValidationReport { + mode: "suite".to_string(), + event: None, + branch, + base_ref: None, + merge_base: None, + head_ref, + changed_files: Vec::new(), + freshness_required: false, + freshness_ok: true, + selected_suites: suites + .iter() + .map(|suite| suite.as_str().to_string()) + .collect(), + suite_results, + }; + finish_report(&context.workspace_root, "suite", &report) +} + +fn run_ci(args: &[String]) -> Result<(), String> { + let mut event = None; + let mut base_ref = None; + let mut head_ref = "HEAD".to_string(); + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "--event" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --event".to_string()); + }; + event = Some(value.clone()); + index += 2; + } + "--base" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --base".to_string()); + }; + base_ref = Some(value.clone()); + index += 2; + } + "--head" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --head".to_string()); + }; + head_ref.clone_from(value); + index += 2; + } + other => return Err(format!("unknown validate ci argument `{other}`")), + } + } + + let event = event.ok_or_else(|| { + "missing `--event `".to_string() + })?; + let context = load_context()?; + let branch = current_branch(&context.workspace_root)?; + let head_sha = resolve_revision(&context.workspace_root, &head_ref)?; + + let (selection, suites) = if matches!(event.as_str(), "merge_group" | "workflow_dispatch") { + ( + None, + vec![ + Suite::Governance, + Suite::Security, + Suite::Core, + Suite::Ui, + Suite::UiHardening, + Suite::Nomad, + Suite::Pulumi, + ], + ) + } else { + let selection = + select_changed_suites(&context, base_ref.as_deref(), &head_ref, false, false)?; + let suites = selection + .suites + .iter() + .copied() + .filter(|suite| !matches!(suite, Suite::Governance | Suite::Security)) + .collect::>(); + (Some(selection), suites) + }; + let suite_results = execute_suites(&context, &suites); + let report = ValidationReport { + mode: "ci".to_string(), + event: Some(event), + branch, + base_ref: selection.as_ref().map(|value| value.base_ref.clone()), + merge_base: selection.as_ref().map(|value| value.merge_base.clone()), + head_ref: head_sha, + changed_files: selection + .as_ref() + .map(|value| value.changed_files.clone()) + .unwrap_or_default(), + freshness_required: false, + freshness_ok: true, + selected_suites: suites + .iter() + .map(|suite| suite.as_str().to_string()) + .collect(), + suite_results, + }; + finish_report(&context.workspace_root, "ci", &report) +} + +fn run_install_hooks(args: &[String]) -> Result<(), String> { + ensure_no_trailing(args, "validate install-hooks")?; + let workspace_root = workspace_root()?; + let hook_path = workspace_root.join(PRE_PUSH_HOOK_PATH); + if !hook_path.is_file() { + return Err(format!( + "required hook script `{}` is missing", + hook_path.display() + )); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(&hook_path) + .map_err(|error| format!("failed to read `{}`: {error}", hook_path.display()))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions) + .map_err(|error| format!("failed to chmod `{}`: {error}", hook_path.display()))?; + } + + run_command(Command::new("git").current_dir(&workspace_root).args([ + "config", + "core.hooksPath", + ".githooks", + ]))?; + println!("installed repository hooks from `.githooks`"); + Ok(()) +} + +fn load_context() -> Result { + let workspace_root = workspace_root()?; + let config = load_validation_config(&workspace_root)?; + Ok(ValidationContext { + workspace_root, + config, + }) +} + +fn load_validation_config(workspace_root: &Path) -> Result { + let raw = fs::read_to_string(workspace_root.join(VALIDATION_CONFIG_PATH)) + .map_err(|error| format!("failed to read `{VALIDATION_CONFIG_PATH}`: {error}"))?; + let config: ValidationConfig = toml::from_str(&raw) + .map_err(|error| format!("failed to parse `{VALIDATION_CONFIG_PATH}`: {error}"))?; + if config.version != 1 { + return Err(format!( + "`{VALIDATION_CONFIG_PATH}` must declare `version = 1`" + )); + } + Ok(config) +} + +fn load_security_exceptions(workspace_root: &Path) -> Result { + let raw = fs::read_to_string(workspace_root.join(SECURITY_EXCEPTIONS_PATH)) + .map_err(|error| format!("failed to read `{SECURITY_EXCEPTIONS_PATH}`: {error}"))?; + let exceptions: SecurityExceptions = toml::from_str(&raw) + .map_err(|error| format!("failed to parse `{SECURITY_EXCEPTIONS_PATH}`: {error}"))?; + if exceptions.version != 1 { + return Err(format!( + "`{SECURITY_EXCEPTIONS_PATH}` must declare `version = 1`" + )); + } + validate_security_exceptions(&exceptions)?; + Ok(exceptions) +} + +fn validate_security_exceptions(exceptions: &SecurityExceptions) -> Result<(), String> { + let mut ids = BTreeSet::new(); + let today = Utc::now().date_naive(); + for exception in &exceptions.exceptions { + if exception.ids.is_empty() { + return Err( + "security exception entries must include at least one advisory id".to_string(), + ); + } + if exception.owner.trim().is_empty() { + return Err("security exception entries must include a non-empty owner".to_string()); + } + if exception.issue == 0 { + return Err("security exception entries must include a non-zero issue id".to_string()); + } + if exception.reason.trim().is_empty() { + return Err("security exception entries must include a non-empty reason".to_string()); + } + let expires = + chrono::NaiveDate::parse_from_str(&exception.expires, "%Y-%m-%d").map_err(|error| { + format!( + "invalid security exception expiry `{}`: {error}", + exception.expires + ) + })?; + if expires < today { + return Err(format!( + "security exception entry for issue `#{}' is expired on `{}`", + exception.issue, exception.expires + )); + } + for id in &exception.ids { + if !ids.insert(id.clone()) { + return Err(format!("duplicate security exception id `{id}`")); + } + } + } + Ok(()) +} + +fn parse_suite_args(args: &[String]) -> Result, String> { + let mut suites = Vec::new(); + for name in args { + match name.as_str() { + "governance" => suites.push(Suite::Governance), + "security" => suites.push(Suite::Security), + "core" => suites.push(Suite::Core), + "ui" => suites.push(Suite::Ui), + "ui-hardening" => suites.push(Suite::UiHardening), + "nomad" => suites.push(Suite::Nomad), + "pulumi" => suites.push(Suite::Pulumi), + "full" => suites.extend([ + Suite::Governance, + Suite::Security, + Suite::Core, + Suite::Ui, + Suite::UiHardening, + Suite::Nomad, + Suite::Pulumi, + ]), + other => return Err(format!("unknown validation suite `{other}`")), + } + } + Ok(dedup_suites(suites)) +} + +fn select_changed_suites( + context: &ValidationContext, + explicit_base_ref: Option<&str>, + head_ref: &str, + fetch_base: bool, + enforce_freshness: bool, +) -> Result { + let base_ref = resolve_base_ref(&context.workspace_root, explicit_base_ref); + if fetch_base { + fetch_base_ref(&context.workspace_root, &base_ref)?; + } + let merge_base = merge_base(&context.workspace_root, &base_ref, head_ref)?; + let head_sha = resolve_revision(&context.workspace_root, head_ref)?; + let changed_files = diff_name_only(&context.workspace_root, &merge_base, &head_sha)?; + let suites = suites_for_changed_files(&changed_files, &context.config); + let freshness_required = enforce_freshness + && changed_files + .iter() + .any(|path| matches_any_prefix(path, &context.config.freshness.required_prefixes)); + let freshness_ok = if freshness_required { + is_ancestor(&context.workspace_root, &base_ref, &head_sha)? + } else { + true + }; + Ok(ChangedSelection { + branch: current_branch(&context.workspace_root)?, + base_ref, + merge_base, + head_ref: head_sha, + changed_files, + suites, + freshness_required, + freshness_ok, + }) +} + +fn suites_for_changed_files(changed_files: &[String], config: &ValidationConfig) -> Vec { + let mut suites = vec![Suite::Governance, Suite::Security]; + let run_core = changed_files.iter().any(|path| { + matches_shared_root(path, &config.selectors) + || matches_any_prefix(path, &config.selectors.core_rust_prefixes) + }); + let run_ui = changed_files.iter().any(|path| { + matches_shared_root(path, &config.selectors) + || matches_any_prefix(path, &config.selectors.ui_prefixes) + }); + let run_nomad = changed_files.iter().any(|path| { + matches_any_prefix(path, &config.selectors.nomad_prefixes) + && config + .selectors + .nomad_suffixes + .iter() + .any(|suffix| path.ends_with(suffix)) + }); + let run_pulumi = changed_files + .iter() + .any(|path| matches_any_prefix(path, &config.selectors.pulumi_prefixes)); + + if run_core { + suites.push(Suite::Core); + } + if run_ui { + suites.push(Suite::Ui); + suites.push(Suite::UiHardening); + } + if run_nomad { + suites.push(Suite::Nomad); + } + if run_pulumi { + suites.push(Suite::Pulumi); + } + + dedup_suites(suites) +} + +fn execute_suites(context: &ValidationContext, suites: &[Suite]) -> Vec { + let mut results = Vec::new(); + for suite in suites { + let start = Instant::now(); + let outcome = run_suite(context, *suite); + let elapsed_ms = start.elapsed().as_millis(); + match outcome { + Ok(detail) => results.push(SuiteResult { + suite: suite.as_str().to_string(), + status: "passed".to_string(), + detail, + elapsed_ms, + }), + Err(detail) => { + results.push(SuiteResult { + suite: suite.as_str().to_string(), + status: "failed".to_string(), + detail: detail.clone(), + elapsed_ms, + }); + break; + } + } + } + results +} + +fn run_suite(context: &ValidationContext, suite: Suite) -> Result { + match suite { + Suite::Governance => { + crate::architecture::run(vec!["audit-boundaries".to_string()])?; + crate::plugin::run(vec!["validate-manifests".to_string()])?; + crate::github::run(vec!["audit-process".to_string()])?; + Ok("architecture, plugin, and process audits passed".to_string()) + } + Suite::Security => run_security_suite(context), + Suite::Core => { + super::cargo(&context.workspace_root, &["fmt", "--all", "--check"])?; + super::cargo( + &context.workspace_root, + &super::workspace_command_with_excludes( + &["clippy", "--workspace", "--all-targets", "--all-features"], + super::CORE_EXCLUDED_PACKAGES, + &["--", "-D", "warnings"], + ), + )?; + super::cargo( + &context.workspace_root, + &super::workspace_command_with_excludes( + &["test", "--workspace", "--all-targets"], + super::CORE_EXCLUDED_PACKAGES, + &[], + ), + )?; + Ok("workspace fmt, core clippy, and core tests passed".to_string()) + } + Suite::Ui => { + super::cargo( + &context.workspace_root, + &super::package_command_with_packages( + &["clippy", "--all-targets", "--all-features"], + super::UI_PACKAGES, + &["--", "-D", "warnings"], + ), + )?; + super::cargo( + &context.workspace_root, + &super::package_command_with_packages( + &["test", "--all-targets"], + super::UI_PACKAGES, + &[], + ), + )?; + super::verify_ui_browser_manifest_hygiene(&context.workspace_root)?; + super::verify_ui_shell_style_hygiene(&context.workspace_root)?; + super::run_ui_preview_smoke(&context.workspace_root)?; + super::run_ui(vec![ + "build".to_string(), + "--features".to_string(), + "desktop-tauri".to_string(), + "--dist".to_string(), + "target/trunk-ci-dist".to_string(), + ])?; + Ok("ui clippy, tests, hygiene, preview smoke, and build passed".to_string()) + } + Suite::UiHardening => { + prepare_ui_browser_tooling(context)?; + crate::ui_hardening::run(Vec::new())?; + Ok("ui hardening verification passed".to_string()) + } + Suite::Nomad => run_nomad_suite(context), + Suite::Pulumi => run_pulumi_suite(context), + } +} + +fn run_security_suite(context: &ValidationContext) -> Result { + let exceptions = load_security_exceptions(&context.workspace_root)?; + if !cargo_subcommand_available(&context.workspace_root, "audit") { + run_command( + Command::new("cargo") + .current_dir(&context.workspace_root) + .args([ + "install", + "cargo-audit", + "--locked", + "--version", + &context.config.tools.cargo_audit_version, + ]), + )?; + } + + let mut command = Command::new("cargo"); + command.current_dir(&context.workspace_root); + command.arg("audit"); + command.arg("--json"); + for exception in &exceptions.exceptions { + for id in &exception.ids { + command.arg("--ignore"); + command.arg(id); + } + } + + let output = command_output(&mut command)?; + let remaining = if output.stdout.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str::(&output.stdout) + .map_err(|error| format!("failed to parse cargo audit JSON output: {error}"))? + }; + let report = render_security_report(&exceptions, &remaining); + write_text_report(&context.workspace_root, "security.md", &report)?; + write_json_report(&context.workspace_root, "security.json", &remaining)?; + + if output.status.success() { + Ok(format!( + "cargo audit passed with {} repo-owned exception group(s)", + exceptions.exceptions.len() + )) + } else { + Err(format!( + "cargo audit reported unapproved findings; see `{REPORT_DIR}/security.md`" + )) + } +} + +fn run_nomad_suite(context: &ValidationContext) -> Result { + ensure_command( + "docker", + &["--version"], + "install Docker before running the Nomad suite", + )?; + let job_dir = context.workspace_root.join("infrastructure/nomad/jobs"); + if job_dir.exists() { + let output = command_output( + Command::new("rg") + .current_dir(&context.workspace_root) + .args([ + "-n", + "driver\\s*=\\s*\"raw_exec\"", + "infrastructure/nomad/jobs", + ]), + )?; + if output.status.success() { + return Err("raw_exec deployments are not allowed for workload services".to_string()); + } + if output.status.code() != Some(1) { + return Err("failed to scan Nomad jobs for raw_exec posture".to_string()); + } + } + + let files = collect_nomad_job_files(&job_dir)?; + for file in files { + run_command( + Command::new("docker") + .current_dir(&context.workspace_root) + .args([ + "run", + "--rm", + "-v", + &format!("{}:/workspace", context.workspace_root.display()), + "-w", + "/workspace", + &context.config.tools.nomad_image, + "nomad", + "job", + "validate", + &relative_to_workspace(&context.workspace_root, &file), + ]), + )?; + } + Ok("nomad posture scan and job validation passed".to_string()) +} + +fn run_pulumi_suite(context: &ValidationContext) -> Result { + ensure_command( + "node", + &["--version"], + "install Node before running the Pulumi suite", + )?; + ensure_command( + "npm", + &["--version"], + "install npm before running the Pulumi suite", + )?; + run_command( + Command::new("npm") + .current_dir(&context.workspace_root) + .args(["ci", "--prefix", "infrastructure/pulumi"]), + )?; + run_command( + Command::new("npm") + .current_dir(context.workspace_root.join("infrastructure/pulumi")) + .arg("test"), + )?; + Ok("pulumi workspace install and tests passed".to_string()) +} + +fn doctor_checks(context: &ValidationContext) -> Vec { + let workspace_root = &context.workspace_root; + let mut checks = Vec::new(); + checks.push(command_check( + "rustc", + Command::new("rustc").arg("--version"), + "Install the pinned Rust toolchain from `rust-toolchain.toml`.", + )); + checks.push(command_check( + "cargo", + Command::new("cargo").arg("--version"), + "Install the pinned Rust toolchain from `rust-toolchain.toml`.", + )); + checks.push(target_check( + workspace_root, + "wasm32-unknown-unknown", + "Run `rustup target add wasm32-unknown-unknown`.", + )); + checks.push(command_check( + "trunk", + Command::new("trunk").arg("--version"), + &format!( + "Run `cargo install trunk --locked --version {}`.", + context.config.tools.trunk_version + ), + )); + checks.push(command_check( + "cargo-audit", + Command::new("cargo").arg("audit").arg("--version"), + &format!( + "Run `cargo install cargo-audit --locked --version {}`.", + context.config.tools.cargo_audit_version + ), + )); + checks.push(command_check( + "node", + Command::new("node").arg("--version"), + "Install Node using the version declared in `.nvmrc`.", + )); + if let Some(node_check) = checks.last_mut() + && node_check.ok + && let Some(major) = node_major_version(&node_check.observed) + && major < context.config.tools.node_major + { + node_check.ok = false; + node_check.remediation = format!( + "Install Node {} or newer using the version declared in `.nvmrc`.", + context.config.tools.node_major + ); + node_check.observed = format!( + "{} (requires Node {} or newer)", + node_check.observed, context.config.tools.node_major + ); + } + checks.push(command_check( + "npm", + Command::new("npm").arg("--version"), + "Install npm alongside Node.", + )); + checks.push(path_check( + "ui/e2e dependencies", + workspace_root + .join("ui/e2e/node_modules/playwright") + .exists(), + "Run `npm ci --prefix ui/e2e`.", + )); + checks.push(path_check( + "Playwright browsers", + playwright_cache_available(), + "Run `npx --prefix ui/e2e playwright install chromium firefox webkit`.", + )); + checks.push(path_check( + "Pulumi workspace dependencies", + workspace_root + .join("infrastructure/pulumi/node_modules") + .exists(), + "Run `npm ci --prefix infrastructure/pulumi`.", + )); + checks.push(command_check( + "docker", + Command::new("docker").arg("--version"), + "Install Docker Desktop or another compatible Docker runtime.", + )); + checks +} + +#[derive(Debug)] +struct DoctorCheck { + name: String, + ok: bool, + observed: String, + remediation: String, +} + +fn command_check(name: &str, command: &mut Command, remediation: &str) -> DoctorCheck { + let result = command_output(command); + match result { + Ok(output) if output.status.success() => DoctorCheck { + name: name.to_string(), + ok: true, + observed: output.stdout.trim().to_string(), + remediation: remediation.to_string(), + }, + Ok(output) => DoctorCheck { + name: name.to_string(), + ok: false, + observed: stderr_or_stdout(&output), + remediation: remediation.to_string(), + }, + Err(error) => DoctorCheck { + name: name.to_string(), + ok: false, + observed: error, + remediation: remediation.to_string(), + }, + } +} + +fn target_check(workspace_root: &Path, target: &str, remediation: &str) -> DoctorCheck { + let output = command_output(Command::new("rustup").current_dir(workspace_root).args([ + "target", + "list", + "--installed", + ])); + match output { + Ok(output) if output.status.success() => { + let installed = output.stdout.lines().any(|line| line.trim() == target); + DoctorCheck { + name: format!("Rust target `{target}`"), + ok: installed, + observed: output.stdout.trim().to_string(), + remediation: remediation.to_string(), + } + } + Ok(output) => DoctorCheck { + name: format!("Rust target `{target}`"), + ok: false, + observed: stderr_or_stdout(&output), + remediation: remediation.to_string(), + }, + Err(error) => DoctorCheck { + name: format!("Rust target `{target}`"), + ok: false, + observed: error, + remediation: remediation.to_string(), + }, + } +} + +fn path_check(name: &str, exists: bool, remediation: &str) -> DoctorCheck { + DoctorCheck { + name: name.to_string(), + ok: exists, + observed: if exists { "available" } else { "missing" }.to_string(), + remediation: remediation.to_string(), + } +} + +fn render_doctor_report(checks: &[DoctorCheck]) -> String { + let mut lines = vec!["# Validation Doctor".to_string(), String::new()]; + for check in checks { + lines.push(format!( + "- [{}] {}: {}", + if check.ok { "PASS" } else { "FAIL" }, + check.name, + check.observed + )); + if !check.ok { + lines.push(format!(" remediation: {}", check.remediation)); + } + } + lines.join("\n") +} + +fn render_security_report( + exceptions: &SecurityExceptions, + remaining: &serde_json::Value, +) -> String { + let mut lines = vec!["# Security Validation".to_string(), String::new()]; + lines.push(format!( + "- exception groups: {}", + exceptions.exceptions.len() + )); + for exception in &exceptions.exceptions { + lines.push(format!( + "- issue #{issue} | expires {expires} | owner {owner} | ids {ids}", + issue = exception.issue, + expires = exception.expires, + owner = exception.owner, + ids = exception.ids.join(", "), + )); + lines.push(format!(" reason: {}", exception.reason)); + } + let remaining_count = remaining + .pointer("/vulnerabilities/count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + lines.push(String::new()); + lines.push(format!( + "- remaining unignored vulnerability count: {remaining_count}" + )); + lines.join("\n") +} + +fn finish_report( + workspace_root: &Path, + stem: &str, + report: &ValidationReport, +) -> Result<(), String> { + write_json_report(workspace_root, &format!("{stem}.json"), report)?; + write_text_report( + workspace_root, + &format!("{stem}.md"), + &render_validation_report(report), + )?; + println!("{}", render_validation_report(report)); + + if report + .suite_results + .iter() + .any(|result| result.status != "passed") + { + Err(format!("validation failed; see `{REPORT_DIR}/{stem}.md`")) + } else { + Ok(()) + } +} + +fn freshness_failure_result(base_ref: &str) -> SuiteResult { + SuiteResult { + suite: "freshness".to_string(), + status: "failed".to_string(), + detail: format!( + "branch is stale relative to `{base_ref}` for conflict-prone paths; refresh from the latest target branch before pushing" + ), + elapsed_ms: 0, + } +} + +fn render_validation_report(report: &ValidationReport) -> String { + let mut lines = vec!["# Validation Report".to_string(), String::new()]; + lines.push(format!("- mode: {}", report.mode)); + if let Some(event) = &report.event { + lines.push(format!("- event: {event}")); + } + lines.push(format!("- branch: {}", report.branch)); + if let Some(base_ref) = &report.base_ref { + lines.push(format!("- base ref: {base_ref}")); + } + if let Some(merge_base) = &report.merge_base { + lines.push(format!("- merge base: {merge_base}")); + } + lines.push(format!("- head ref: {}", report.head_ref)); + lines.push(format!( + "- freshness: {}", + if report.freshness_required { + if report.freshness_ok { + "required and satisfied" + } else { + "required and failed" + } + } else { + "not required" + } + )); + lines.push(format!( + "- selected suites: {}", + if report.selected_suites.is_empty() { + "none".to_string() + } else { + report.selected_suites.join(", ") + } + )); + if !report.changed_files.is_empty() { + lines.push("- changed files:".to_string()); + lines.extend( + report + .changed_files + .iter() + .map(|path| format!(" - {path}")), + ); + } + lines.push(String::new()); + lines.push("## Suites".to_string()); + for result in &report.suite_results { + lines.push(format!( + "- [{}] {} ({} ms): {}", + if result.status == "passed" { + "PASS" + } else { + "FAIL" + }, + result.suite, + result.elapsed_ms, + result.detail + )); + } + lines.join("\n") +} + +fn write_json_report( + workspace_root: &Path, + name: &str, + value: &T, +) -> Result<(), String> { + let report_dir = workspace_root.join(REPORT_DIR); + fs::create_dir_all(&report_dir) + .map_err(|error| format!("failed to create `{}`: {error}", report_dir.display()))?; + fs::write( + report_dir.join(name), + serde_json::to_vec_pretty(value) + .map_err(|error| format!("failed to serialize validation report: {error}"))?, + ) + .map_err(|error| { + format!( + "failed to write `{}`: {error}", + report_dir.join(name).display() + ) + }) +} + +fn write_text_report(workspace_root: &Path, name: &str, contents: &str) -> Result<(), String> { + let report_dir = workspace_root.join(REPORT_DIR); + fs::create_dir_all(&report_dir) + .map_err(|error| format!("failed to create `{}`: {error}", report_dir.display()))?; + fs::write(report_dir.join(name), contents).map_err(|error| { + format!( + "failed to write `{}`: {error}", + report_dir.join(name).display() + ) + }) +} + +fn prepare_ui_browser_tooling(context: &ValidationContext) -> Result<(), String> { + ensure_command( + "node", + &["--version"], + "install Node before running UI hardening", + )?; + ensure_command( + "npm", + &["--version"], + "install npm before running UI hardening", + )?; + run_command( + Command::new("npm") + .current_dir(&context.workspace_root) + .args(["ci", "--prefix", "ui/e2e"]), + )?; + run_command(&mut playwright_install_command( + &context.workspace_root, + env::var_os("CI").is_some(), + ))?; + Ok(()) +} + +fn playwright_install_command(workspace_root: &Path, ci_mode: bool) -> Command { + let mut command = Command::new("npx"); + command.current_dir(workspace_root); + command.args(["--prefix", "ui/e2e", "playwright", "install"]); + if ci_mode && cfg!(target_os = "linux") { + command.arg("--with-deps"); + } + command.args(["chromium", "firefox", "webkit"]); + command +} + +fn resolve_base_ref(workspace_root: &Path, explicit: Option<&str>) -> String { + if let Some(base) = explicit { + return base.to_string(); + } + if let Ok(output) = command_output(Command::new("git").current_dir(workspace_root).args([ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ])) && output.status.success() + { + let upstream = output.stdout.trim(); + if !upstream.is_empty() { + return upstream.to_string(); + } + } + "origin/main".to_string() +} + +fn fetch_base_ref(workspace_root: &Path, base_ref: &str) -> Result<(), String> { + let remote = base_ref + .strip_prefix("refs/remotes/") + .and_then(|value| value.split('/').next()) + .or_else(|| base_ref.split('/').next()) + .filter(|value| *value != "HEAD") + .unwrap_or("origin"); + run_command( + Command::new("git") + .current_dir(workspace_root) + .args(["fetch", remote]), + ) +} + +fn current_branch(workspace_root: &Path) -> Result { + let output = command_output( + Command::new("git") + .current_dir(workspace_root) + .args(["branch", "--show-current"]), + )?; + let branch = output.stdout.trim(); + if branch.is_empty() { + Ok("detached".to_string()) + } else { + Ok(branch.to_string()) + } +} + +fn resolve_revision(workspace_root: &Path, rev: &str) -> Result { + let output = command_output( + Command::new("git") + .current_dir(workspace_root) + .args(["rev-parse", rev]), + )?; + if output.status.success() { + Ok(output.stdout.trim().to_string()) + } else { + Err(stderr_or_stdout(&output)) + } +} + +fn merge_base(workspace_root: &Path, base_ref: &str, head_ref: &str) -> Result { + let output = command_output(Command::new("git").current_dir(workspace_root).args([ + "merge-base", + base_ref, + head_ref, + ]))?; + if output.status.success() { + Ok(output.stdout.trim().to_string()) + } else { + Err(stderr_or_stdout(&output)) + } +} + +fn is_ancestor(workspace_root: &Path, base_ref: &str, head_ref: &str) -> Result { + let output = command_output(Command::new("git").current_dir(workspace_root).args([ + "merge-base", + "--is-ancestor", + base_ref, + head_ref, + ]))?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(stderr_or_stdout(&output)), + } +} + +fn diff_name_only(workspace_root: &Path, base: &str, head: &str) -> Result, String> { + let output = command_output(Command::new("git").current_dir(workspace_root).args([ + "diff", + "--name-only", + &format!("{base}..{head}"), + ]))?; + if !output.status.success() { + return Err(stderr_or_stdout(&output)); + } + Ok(output + .stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect()) +} + +fn matches_shared_root(path: &str, selectors: &SelectorConfig) -> bool { + selectors + .shared_root_files + .iter() + .any(|candidate| path == candidate) + || matches_any_prefix(path, &selectors.shared_root_prefixes) +} + +fn matches_any_prefix(path: &str, prefixes: &[String]) -> bool { + prefixes.iter().any(|prefix| path.starts_with(prefix)) +} + +fn dedup_suites(suites: Vec) -> Vec { + let mut seen = BTreeSet::new(); + let mut deduped = Vec::new(); + for suite in suites { + if seen.insert(suite) { + deduped.push(suite); + } + } + deduped +} + +fn command_available(program: &str) -> bool { + Command::new(program) + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn cargo_subcommand_available(workspace_root: &Path, subcommand: &str) -> bool { + Command::new("cargo") + .current_dir(workspace_root) + .arg(subcommand) + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn ensure_command(program: &str, args: &[&str], remediation: &str) -> Result<(), String> { + if !command_available(program) { + return Err(remediation.to_string()); + } + let status = Command::new(program) + .args(args) + .status() + .map_err(|error| format!("failed to launch `{program}`: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(remediation.to_string()) + } +} + +fn command_output(command: &mut Command) -> Result { + let output = command + .output() + .map_err(|error| format!("failed to start `{}`: {error}", display_command(command)))?; + Ok(CapturedOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + +struct CapturedOutput { + status: std::process::ExitStatus, + stdout: String, + stderr: String, +} + +fn stderr_or_stdout(output: &CapturedOutput) -> String { + if !output.stderr.trim().is_empty() { + output.stderr.trim().to_string() + } else if !output.stdout.trim().is_empty() { + output.stdout.trim().to_string() + } else { + "command failed without output".to_string() + } +} + +fn collect_nomad_job_files(root: &Path) -> Result, String> { + if !root.exists() { + return Ok(Vec::new()); + } + let mut files = Vec::new(); + collect_nomad_job_files_recursive(root, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_nomad_job_files_recursive(root: &Path, files: &mut Vec) -> Result<(), String> { + for entry in fs::read_dir(root) + .map_err(|error| format!("failed to read `{}`: {error}", root.display()))? + { + let entry = entry + .map_err(|error| format!("failed to read entry in `{}`: {error}", root.display()))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("failed to inspect `{}`: {error}", path.display()))?; + if file_type.is_dir() { + collect_nomad_job_files_recursive(&path, files)?; + continue; + } + if path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.ends_with(".nomad.hcl")) + { + files.push(path); + } + } + Ok(()) +} + +fn relative_to_workspace(workspace_root: &Path, path: &Path) -> String { + path.strip_prefix(workspace_root) + .unwrap_or(path) + .display() + .to_string() +} + +fn playwright_cache_available() -> bool { + let mut candidates = Vec::new(); + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + candidates.push(home.join("Library/Caches/ms-playwright")); + candidates.push(home.join(".cache/ms-playwright")); + } + if let Some(local_app_data) = env::var_os("LOCALAPPDATA") { + candidates.push(PathBuf::from(local_app_data).join("ms-playwright")); + } + candidates.into_iter().any(|path| path.exists()) +} + +fn node_major_version(raw: &str) -> Option { + let trimmed = raw.trim().trim_start_matches('v'); + trimmed.split('.').next()?.parse().ok() +} + +fn ensure_no_trailing(args: &[String], command: &str) -> Result<(), String> { + if args.is_empty() { + Ok(()) + } else { + Err(format!("unexpected trailing arguments for `{command}`")) + } +} + +impl Suite { + fn as_str(self) -> &'static str { + match self { + Suite::Governance => "governance", + Suite::Security => "security", + Suite::Core => "core", + Suite::Ui => "ui", + Suite::UiHardening => "ui-hardening", + Suite::Nomad => "nomad", + Suite::Pulumi => "pulumi", + } + } +} + +fn help() -> &'static str { + "\ +usage: cargo xtask validate ... + +Commands: + doctor Validate local prerequisites and report remediation commands + bootstrap Install or prepare native-first local validation dependencies + changed Run repo-owned validation suites selected from changed files + suite Run one or more explicit validation suites + ci Run repo-owned CI suite selection and execution for GitHub workflows + install-hooks Install blocking repository pre-push hooks +" +} + +#[cfg(test)] +mod tests { + use super::{ + SecurityException, SecurityExceptions, Suite, ValidationContext, dedup_suites, + load_validation_config, resolve_base_ref, select_changed_suites, suites_for_changed_files, + validate_security_exceptions, + }; + use chrono::{Duration, Utc}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + use tempfile::TempDir; + + #[test] + fn suites_for_ui_only_changes_include_ui_hardening() { + let workspace_root = crate::common::workspace_root().expect("workspace root"); + let config = load_validation_config(&workspace_root).expect("load validation config"); + let suites = + suites_for_changed_files(&["ui/crates/site/src/web_app.rs".to_string()], &config); + assert_eq!( + suites, + vec![ + Suite::Governance, + Suite::Security, + Suite::Ui, + Suite::UiHardening + ] + ); + } + + #[test] + fn suites_for_service_changes_include_core_only() { + let workspace_root = crate::common::workspace_root().expect("workspace root"); + let config = load_validation_config(&workspace_root).expect("load validation config"); + let suites = suites_for_changed_files( + &["services/finance-service/src/lib.rs".to_string()], + &config, + ); + assert_eq!( + suites, + vec![Suite::Governance, Suite::Security, Suite::Core] + ); + } + + #[test] + fn suites_for_shared_root_changes_include_core_and_ui() { + let workspace_root = crate::common::workspace_root().expect("workspace root"); + let config = load_validation_config(&workspace_root).expect("load validation config"); + let suites = suites_for_changed_files(&["Cargo.toml".to_string()], &config); + assert_eq!( + suites, + vec![ + Suite::Governance, + Suite::Security, + Suite::Core, + Suite::Ui, + Suite::UiHardening + ] + ); + } + + #[test] + fn suites_for_nomad_and_pulumi_changes_include_infra_suites() { + let workspace_root = crate::common::workspace_root().expect("workspace root"); + let config = load_validation_config(&workspace_root).expect("load validation config"); + let suites = suites_for_changed_files( + &[ + "infrastructure/nomad/jobs/app.nomad.hcl".to_string(), + "infrastructure/pulumi/package.json".to_string(), + ], + &config, + ); + assert_eq!( + suites, + vec![ + Suite::Governance, + Suite::Security, + Suite::Nomad, + Suite::Pulumi + ] + ); + } + + #[test] + fn dedup_suites_preserves_order() { + assert_eq!( + dedup_suites(vec![Suite::Governance, Suite::Security, Suite::Governance]), + vec![Suite::Governance, Suite::Security] + ); + } + + #[test] + fn security_exceptions_require_non_empty_owner() { + let error = validate_security_exceptions(&SecurityExceptions { + version: 1, + exceptions: vec![SecurityException { + ids: vec!["RUSTSEC-2099-0001".to_string()], + owner: String::new(), + issue: 139, + expires: future_expiry(), + reason: "tracked".to_string(), + }], + }) + .expect_err("missing owner should fail"); + assert!(error.contains("non-empty owner")); + } + + #[test] + fn security_exceptions_reject_duplicate_ids() { + let error = validate_security_exceptions(&SecurityExceptions { + version: 1, + exceptions: vec![ + SecurityException { + ids: vec!["RUSTSEC-2099-0001".to_string()], + owner: "@owner".to_string(), + issue: 139, + expires: future_expiry(), + reason: "tracked".to_string(), + }, + SecurityException { + ids: vec!["RUSTSEC-2099-0001".to_string()], + owner: "@owner".to_string(), + issue: 140, + expires: future_expiry(), + reason: "tracked".to_string(), + }, + ], + }) + .expect_err("duplicate ids should fail"); + assert!(error.contains("duplicate security exception id")); + } + + #[test] + fn security_exceptions_require_issue_link() { + let error = validate_security_exceptions(&SecurityExceptions { + version: 1, + exceptions: vec![SecurityException { + ids: vec!["RUSTSEC-2099-0001".to_string()], + owner: "@owner".to_string(), + issue: 0, + expires: future_expiry(), + reason: "tracked".to_string(), + }], + }) + .expect_err("missing issue should fail"); + assert!(error.contains("non-zero issue id")); + } + + #[test] + fn security_exceptions_require_valid_expiry() { + let error = validate_security_exceptions(&SecurityExceptions { + version: 1, + exceptions: vec![SecurityException { + ids: vec!["RUSTSEC-2099-0001".to_string()], + owner: "@owner".to_string(), + issue: 139, + expires: String::new(), + reason: "tracked".to_string(), + }], + }) + .expect_err("missing expiry should fail"); + assert!(error.contains("invalid security exception expiry")); + } + + #[test] + fn security_exceptions_reject_expired_entries() { + let error = validate_security_exceptions(&SecurityExceptions { + version: 1, + exceptions: vec![SecurityException { + ids: vec!["RUSTSEC-2099-0001".to_string()], + owner: "@owner".to_string(), + issue: 139, + expires: expired_expiry(), + reason: "tracked".to_string(), + }], + }) + .expect_err("expired exception should fail"); + assert!(error.contains("expired")); + } + + #[test] + fn resolve_base_ref_uses_upstream_branch_when_available() { + let (_tempdir, repo) = init_git_repo(); + commit_file(&repo, "README.md", "base\n", "base"); + run_git(&repo, &["push", "-u", "origin", "main"]); + + run_git(&repo, &["checkout", "-b", "feature/139-parent"]); + commit_file(&repo, "parent.txt", "parent\n", "parent"); + run_git(&repo, &["push", "-u", "origin", "feature/139-parent"]); + + run_git(&repo, &["checkout", "-b", "feature/139-child"]); + run_git( + &repo, + &[ + "branch", + "--set-upstream-to", + "origin/feature/139-parent", + "feature/139-child", + ], + ); + + let base = resolve_base_ref(&repo, None); + assert_eq!(base, "origin/feature/139-parent"); + } + + #[test] + fn resolve_base_ref_falls_back_to_origin_main_without_upstream() { + let (_tempdir, repo) = init_git_repo(); + commit_file(&repo, "README.md", "base\n", "base"); + run_git(&repo, &["push", "-u", "origin", "main"]); + run_git(&repo, &["checkout", "-b", "feature/139-local-only"]); + + let base = resolve_base_ref(&repo, None); + assert_eq!(base, "origin/main"); + } + + #[test] + fn stale_hotspot_branches_require_refresh() { + let (_tempdir, repo) = init_git_repo(); + commit_file(&repo, "README.md", "base\n", "base"); + run_git(&repo, &["push", "-u", "origin", "main"]); + + run_git(&repo, &["checkout", "-b", "feature/139-ui-change"]); + commit_file( + &repo, + "ui/crates/system_ui/src/lib.rs", + "feature change\n", + "ui change", + ); + + run_git(&repo, &["checkout", "main"]); + commit_file(&repo, "shared/stability.txt", "main drift\n", "main drift"); + run_git(&repo, &["push", "origin", "main"]); + run_git(&repo, &["checkout", "feature/139-ui-change"]); + run_git(&repo, &["fetch", "origin"]); + + let workspace_root = crate::common::workspace_root().expect("workspace root"); + let config = load_validation_config(&workspace_root).expect("load validation config"); + let context = ValidationContext { + workspace_root: repo.clone(), + config, + }; + let selection = select_changed_suites(&context, Some("origin/main"), "HEAD", false, true) + .expect("select changed suites"); + assert!(selection.freshness_required); + assert!(!selection.freshness_ok); + assert!( + selection + .changed_files + .iter() + .any(|path| path == "ui/crates/system_ui/src/lib.rs") + ); + assert_eq!( + selection + .suites + .iter() + .map(|suite| suite.as_str()) + .collect::>(), + vec!["governance", "security", "ui", "ui-hardening"] + ); + } + + fn future_expiry() -> String { + (Utc::now().date_naive() + Duration::days(30)) + .format("%Y-%m-%d") + .to_string() + } + + fn expired_expiry() -> String { + (Utc::now().date_naive() - Duration::days(1)) + .format("%Y-%m-%d") + .to_string() + } + + fn init_git_repo() -> (TempDir, PathBuf) { + let tempdir = tempfile::tempdir().expect("create tempdir"); + let origin = tempdir.path().join("origin.git"); + let repo = tempdir.path().join("repo"); + + run_git( + tempdir.path(), + &["init", "--bare", origin.to_str().expect("origin path")], + ); + run_git( + tempdir.path(), + &[ + "clone", + origin.to_str().expect("origin path"), + repo.to_str().expect("repo path"), + ], + ); + run_git(&repo, &["config", "user.name", "Origin Validation Tests"]); + run_git( + &repo, + &["config", "user.email", "validation-tests@example.com"], + ); + run_git(&repo, &["checkout", "-b", "main"]); + + (tempdir, repo) + } + + fn commit_file(repo: &Path, relative_path: &str, contents: &str, message: &str) { + let path = repo.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directories"); + } + fs::write(&path, contents).expect("write file"); + run_git(repo, &["add", relative_path]); + run_git(repo, &["commit", "-m", message]); + } + + fn run_git(repo: &Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(repo) + .args(args) + .output() + .expect("launch git"); + assert!( + output.status.success(), + "git {:?} failed:\nstdout:\n{}\nstderr:\n{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/xtask/validation.toml b/xtask/validation.toml new file mode 100644 index 0000000..94dea1b --- /dev/null +++ b/xtask/validation.toml @@ -0,0 +1,48 @@ +version = 1 + +[tools] +node_major = 20 +cargo_audit_version = "0.22.1" +trunk_version = "0.21.14" +nomad_image = "hashicorp/nomad:1.8.4" + +[selectors] +shared_root_files = [ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "README.md", + "CONTRIBUTING.md", + "DEVELOPMENT_MODEL.md", + "ARCHITECTURE.md", + ".nvmrc", +] +shared_root_prefixes = [ + ".cargo/", + ".github/workflows/", + ".github/actions/", + "xtask/", + "shared/", + "schemas/", + "platform/", + "enterprise/", + "workflows/", +] +core_rust_prefixes = ["services/", "agents/"] +ui_prefixes = ["ui/"] +nomad_prefixes = ["infrastructure/nomad/"] +nomad_suffixes = [".nomad.hcl"] +pulumi_prefixes = ["infrastructure/pulumi/"] + +[freshness] +required_prefixes = [ + "ui/", + "shared/", + "platform/", + "schemas/", + ".github/", + "infrastructure/wasmcloud/manifests/", + "ui/crates/desktop_runtime/", + "ui/crates/system_ui/", + "ui/crates/site/src/generated/", +]