diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ba6681c..66b9f5c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -15,7 +15,7 @@ body: attributes: label: PliegoCSS version description: Exact crate version, CLI version, or commit SHA. - placeholder: 0.1.0-rc.2 + placeholder: 0.1.0-rc.3 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8095032..3b9dba0 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,3 +6,6 @@ contact_links: - name: Security report url: https://github.com/celiumsai/pliegocss/security/advisories/new about: Report vulnerabilities privately. Do not open a public issue. + - name: G7 external adoption cohort + url: https://github.com/celiumsai/pliegocss/issues/7 + about: Read the cohort boundary and apply without posting private evidence. diff --git a/.github/ISSUE_TEMPLATE/g7_pilot.yml b/.github/ISSUE_TEMPLATE/g7_pilot.yml new file mode 100644 index 0000000..e846cc7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/g7_pilot.yml @@ -0,0 +1,73 @@ +name: G7 pilot interest +description: Signal interest in the external adoption cohort without posting private material. +title: "[G7 pilot]: " +labels: + - g7-pilot +body: + - type: markdown + attributes: + value: | + This public form is a recruitment signal only. It is not consent and does not count as + evidence. Do not post names, email addresses, credentials, private repository URLs, + proprietary source, transcripts, or incident material. + + Read the [cohort notice](https://github.com/celiumsai/pliegocss/issues/7) and + [privacy notice](https://pliegocss.dev/legal/privacy/). Private intake uses + hello@pliegocss.dev after eligibility review. + - type: dropdown + id: adapter + attributes: + label: Candidate adapter + options: + - Static HTML + - Vite 8.1.5 + - Pinned PliegoRS rendered output + - Outside the certified pilot matrix + validations: + required: true + - type: dropdown + id: tailwind + attributes: + label: Tailwind baseline + options: + - Tailwind CSS 3.4.19 + - Tailwind CSS 4.3.3 + - Another version + - Not currently using Tailwind + validations: + required: true + - type: dropdown + id: visibility + attributes: + label: Project visibility + options: + - Public project + - Private project — details will be shared only through private intake + validations: + required: true + - type: input + id: public_project + attributes: + label: Public project URL + description: Optional. Leave blank for a private project; never paste a private URL. + placeholder: https://github.com/example/project + - type: textarea + id: goal + attributes: + label: Pilot goal + description: Describe the adoption question without source code or identifying private details. + validations: + required: true + - type: checkboxes + id: boundary + attributes: + label: Eligibility and privacy boundary + options: + - label: I maintain or control the candidate project. + required: true + - label: The project is not owned by Celiums Solutions, PliegoCSS, or celiumsai. + required: true + - label: This issue contains no personal data, credentials, proprietary source, or private evidence. + required: true + - label: I understand this public issue is not research consent and does not count as G7 evidence. + required: true diff --git a/.github/workflows/adapter-coexistence.yml b/.github/workflows/adapter-coexistence.yml new file mode 100644 index 0000000..ef43146 --- /dev/null +++ b/.github/workflows/adapter-coexistence.yml @@ -0,0 +1,139 @@ +name: Adapter coexistence certification + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + host: + name: ${{ matrix.host }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: windows-x64-chromium + runner: windows-latest + - host: linux-x64-chromium + runner: ubuntu-24.04 + - host: macos-arm64-chromium + runner: macos-15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.13.0 + - name: Install pnpm + run: npm install --global pnpm@11.7.0 + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate adapter authority + run: pnpm check:adapter-coexistence-authority + - name: Install Rust toolchain + run: rustup toolchain install 1.96.0 --profile minimal + - name: Install Chromium and Linux dependencies + if: runner.os == 'Linux' + run: pnpm exec playwright install --with-deps chromium + - name: Install Chromium + if: runner.os != 'Linux' + run: pnpm exec playwright install chromium + - name: Execute adapter coexistence host + run: pnpm check:adapter-coexistence -- --browser=chromium --host=${{ matrix.host }} --output=target/adapter-coexistence-certification/evidence/${{ matrix.host }}.json --require-clean + - name: Upload host evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: adapter-coexistence-${{ matrix.host }}-${{ github.sha }} + path: target/adapter-coexistence-certification/evidence/${{ matrix.host }}.json + if-no-files-found: error + retention-days: 30 + + pliegors-framework: + name: PliegoRS framework/browser adapter + runs-on: windows-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + path: pliegocss + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: celiumsai/pliegors + ref: abb8653e75da4a5cb4dd9b51200114fbb1e760c7 + path: pliegors + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.13.0 + - name: Install pnpm + run: npm install --global pnpm@11.7.0 + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + working-directory: pliegocss + - name: Install PliegoRS Rust toolchain + run: | + rustup toolchain install 1.86.0 --profile minimal + rustup toolchain install 1.96.0 --profile minimal + rustup target add wasm32-unknown-unknown --toolchain 1.86.0 + - name: Install pinned wasm-bindgen CLI + run: | + cargo +1.96.0 install wasm-bindgen-cli --version 0.2.126 --locked --root "$env:RUNNER_TEMP\wasm-bindgen-cli" + "$env:RUNNER_TEMP\wasm-bindgen-cli\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Execute pinned PliegoRS browser gate + run: pnpm integration:pliegors-browser + working-directory: pliegocss + env: + PLIEGORS_ROOT: ${{ github.workspace }}\pliegors + PLIEGOCSS_PLIEGORS_BROWSER_EVIDENCE: target/adapter-coexistence-certification/pliegors-framework.json + - name: Upload PliegoRS evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: adapter-coexistence-pliegors-framework-${{ github.sha }} + path: pliegocss/target/adapter-coexistence-certification/pliegors-framework.json + if-no-files-found: error + retention-days: 30 + + matrix: + name: Aggregate adapter coexistence matrix + needs: [host, pliegors-framework] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.13.0 + - name: Install pnpm + run: npm install --global pnpm@11.7.0 + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate adapter authority + run: pnpm check:adapter-coexistence-authority + - name: Download host evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: adapter-coexistence-*-chromium-${{ github.sha }} + path: target/adapter-coexistence-certification/incoming + merge-multiple: true + - name: Download PliegoRS evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: adapter-coexistence-pliegors-framework-${{ github.sha }} + path: target/adapter-coexistence-certification/pliegors + - name: Validate complete matrix + run: pnpm check:adapter-coexistence-matrix -- --evidence-root=target/adapter-coexistence-certification/incoming --pliegors-evidence=target/adapter-coexistence-certification/pliegors/pliegors-framework.json --output=target/adapter-coexistence-certification/adapter-coexistence-matrix.json --require-clean + - name: Upload complete matrix + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: adapter-coexistence-matrix-${{ github.sha }} + path: target/adapter-coexistence-certification/adapter-coexistence-matrix.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/browser-output-certification.yml b/.github/workflows/browser-output-certification.yml new file mode 100644 index 0000000..28b3a1e --- /dev/null +++ b/.github/workflows/browser-output-certification.yml @@ -0,0 +1,111 @@ +name: Browser output certification + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + host: + name: ${{ matrix.host }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - host: windows-x64-chromium + runner: windows-latest + browser: chromium + - host: windows-x64-firefox + runner: windows-latest + browser: firefox + - host: windows-x64-webkit + runner: windows-latest + browser: webkit + - host: linux-x64-chromium + runner: ubuntu-24.04 + browser: chromium + - host: linux-x64-firefox + runner: ubuntu-24.04 + browser: firefox + - host: linux-x64-webkit + runner: ubuntu-24.04 + browser: webkit + - host: macos-arm64-chromium + runner: macos-15 + browser: chromium + - host: macos-arm64-firefox + runner: macos-15 + browser: firefox + - host: macos-arm64-webkit + runner: macos-15 + browser: webkit + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.13.0 + - name: Install pnpm + run: npm install --global pnpm@11.7.0 + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate certification authority + run: pnpm check:browser-output-authority + - name: Install Rust toolchain + run: rustup toolchain install 1.96.0 --profile minimal + - name: Install browser and Linux dependencies + if: runner.os == 'Linux' + run: pnpm exec playwright install --with-deps ${{ matrix.browser }} + - name: Install browser + if: runner.os != 'Linux' + run: pnpm exec playwright install ${{ matrix.browser }} + - name: Execute browser/output host + run: pnpm check:browser-output -- --browser=${{ matrix.browser }} --host=${{ matrix.host }} --output=target/browser-output-certification/evidence/${{ matrix.host }}.json --require-clean + - name: Upload host evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: browser-output-${{ matrix.host }}-${{ github.sha }} + path: | + target/browser-output-certification/evidence/${{ matrix.host }}.json + target/browser-output-certification/evidence/${{ matrix.host }}.screenshots + if-no-files-found: error + retention-days: 30 + + matrix: + name: Aggregate browser/output matrix + needs: host + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.13.0 + - name: Install pnpm + run: npm install --global pnpm@11.7.0 + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate certification authority + run: pnpm check:browser-output-authority + - name: Download all host evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: browser-output-*-${{ github.sha }} + path: target/browser-output-certification/incoming + merge-multiple: true + - name: Validate complete matrix + run: pnpm check:browser-output-matrix -- --evidence-root=target/browser-output-certification/incoming --output=target/browser-output-certification/browser-output-matrix.json + - name: Upload complete matrix + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: browser-output-matrix-${{ github.sha }} + path: target/browser-output-certification/browser-output-matrix.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be77296..8acbfc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,7 @@ jobs: env: PLIEGO_CSSC: target/debug/pliego-cssc${{ runner.os == 'Windows' && '.exe' || '' }} PLIEGO_LOCK_HOLDER: target/debug/pliego-publication-lock-holder${{ runner.os == 'Windows' && '.exe' || '' }} + PLIEGOCSS_RUST_TOOLCHAIN: ${{ matrix.toolchain }} run: node scripts/check-parallel-determinism.mjs - name: Check cross-platform bundle contract run: node scripts/check-portability.mjs @@ -120,6 +121,10 @@ jobs: node-version: 22.13.0 - name: Install pnpm for documentation gates run: npm install --global pnpm@11.7.0 + - name: Install Rust toolchains + run: | + rustup toolchain install 1.85.0 --profile minimal + rustup toolchain install 1.96.0 --profile minimal --component rustfmt - name: Verify release profile registry run: node scripts/check-release.test.mjs - name: Check documentation graph @@ -128,8 +133,6 @@ jobs: run: node scripts/check-brand.mjs - name: Check compatibility policy run: node scripts/check-compatibility-policy.mjs - - name: Install rustfmt - run: rustup toolchain install 1.96.0 --profile minimal --component rustfmt - name: Check formatting run: | cargo +1.96.0 fmt --all -- --check @@ -247,6 +250,18 @@ jobs: rustup target add wasm32-unknown-unknown --toolchain 1.96.0 - name: Install benchmark dependencies run: pnpm install --frozen-lockfile + - name: Check Benchmark Authority v2 + run: pnpm check:benchmark-authority + - name: Check browser/output certification authority + run: pnpm check:browser-output-authority + - name: Check adapter coexistence authority + run: pnpm check:adapter-coexistence-authority + - name: Check external adoption authority + run: pnpm check:external-adoption-authority + - name: Check repository-only pnpm distribution contract + run: pnpm check:repository-distribution + - name: Smoke Benchmark Authority v2 runner + run: pnpm check:benchmark-smoke - name: Build and validate the PliegoRS-authored website run: pnpm check:site - name: Validate the Cloudflare public deployment profile diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml new file mode 100644 index 0000000..024ebf0 --- /dev/null +++ b/.github/workflows/distribution.yml @@ -0,0 +1,254 @@ +name: Repository distribution + +on: + push: + pull_request: + workflow_dispatch: + inputs: + attest: + description: Generate GitHub/Sigstore attestations for this exact source + required: true + type: boolean + default: false + +concurrency: + group: repository-distribution-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +permissions: + contents: read + +jobs: + native: + name: Native ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + runner: windows-latest + extension: .exe + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + extension: "" + - target: aarch64-apple-darwin + runner: macos-15 + extension: "" + env: + CARGO_TARGET_DIR: target/distribution-build/${{ matrix.target }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.7.0 + run_install: false + - name: Install pinned Rust and SBOM tooling + run: | + rustup toolchain install 1.96.0 --profile minimal --target ${{ matrix.target }} + cargo +1.96.0 install cargo-cyclonedx --version '=0.5.9' --locked + - name: Build native CLI and LSP + run: cargo +1.96.0 build --release --locked --target ${{ matrix.target }} -p pliego-cssc -p pliego-css-lsp + - name: Generate target-specific CycloneDX SBOMs + run: cargo cyclonedx --format json --target ${{ matrix.target }} --all-features --spec-version 1.5 + - name: Stage exact-source native payload + id: stage + shell: bash + run: | + set -euo pipefail + tree="$(git show -s --format=%T HEAD)" + args=( + "--target=${{ matrix.target }}" + "--binary-dir=target/distribution-build/${{ matrix.target }}/${{ matrix.target }}/release" + "--cli-sbom=crates/pliego-cssc/pliego-cssc.cdx.json" + "--lsp-sbom=crates/pliego-css-lsp/pliego-css-lsp.cdx.json" + "--output=target/distribution-native/${{ matrix.target }}" + "--source-commit=${GITHUB_SHA}" + "--git-tree=${tree}" + ) + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + args+=("--tag=${GITHUB_REF_NAME}") + fi + node scripts/prepare-native-distribution.mjs "${args[@]}" + "target/distribution-native/${{ matrix.target }}/pliego-cssc${{ matrix.extension }}" --version + "target/distribution-native/${{ matrix.target }}/pliego-css-lsp${{ matrix.extension }}" --version + - name: Attest CLI binary and SBOM + if: ${{ github.ref_type == 'tag' || (github.event_name == 'workflow_dispatch' && inputs.attest) }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: target/distribution-native/${{ matrix.target }}/pliego-cssc${{ matrix.extension }} + sbom-path: target/distribution-native/${{ matrix.target }}/pliego-cssc.cdx.json + - name: Attest LSP binary and SBOM + if: ${{ github.ref_type == 'tag' || (github.event_name == 'workflow_dispatch' && inputs.attest) }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: target/distribution-native/${{ matrix.target }}/pliego-css-lsp${{ matrix.extension }} + sbom-path: target/distribution-native/${{ matrix.target }}/pliego-css-lsp.cdx.json + - name: Upload native payload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-${{ matrix.target }} + path: target/distribution-native/${{ matrix.target }} + if-no-files-found: error + retention-days: 30 + + assemble: + name: Assemble repository assets + needs: native + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.7.0 + run_install: false + - name: Verify pinned pnpm + run: test "$(pnpm --version)" = "11.7.0" + - name: Install locked repository dependencies + run: pnpm install --frozen-lockfile + - name: Validate repository distribution source contract + run: pnpm check:repository-distribution + - name: Download all native payloads + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: native-* + path: target/distribution-incoming + merge-multiple: false + - name: Assemble and validate release assets + shell: bash + run: | + set -euo pipefail + tree="$(git show -s --format=%T HEAD)" + args=( + "--input=target/distribution-incoming" + "--output=target/distribution" + "--source-commit=${GITHUB_SHA}" + "--git-tree=${tree}" + ) + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + args+=("--tag=${GITHUB_REF_NAME}") + fi + node scripts/build-repository-distribution.mjs "${args[@]}" + pnpm check:distribution-bundle -- --root=target/distribution/release-assets + - name: Upload complete repository distribution + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: repository-distribution-${{ github.sha }} + path: target/distribution/release-assets + if-no-files-found: error + retention-days: 30 + + install: + name: pnpm install ${{ matrix.host }} + needs: assemble + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - host: windows-x64 + runner: windows-latest + - host: linux-x64 + runner: ubuntu-24.04 + - host: macos-arm64 + runner: macos-15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13.0 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.7.0 + run_install: false + - name: Verify pinned pnpm + shell: bash + run: test "$(pnpm --version)" = "11.7.0" + - name: Download complete repository distribution + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repository-distribution-${{ github.sha }} + path: target/distribution/release-assets + - name: Install verified local asset with pnpm and execute both binaries + shell: bash + run: | + version="$(node -p "require('./packages/cli/package.json').version")" + node scripts/check-installed-repository-package.mjs \ + "--tarball=target/distribution/release-assets/pliegocss-pnpm-${version}.tgz" + + attest-release-assets: + name: Attest release assets + if: ${{ github.ref_type == 'tag' || (github.event_name == 'workflow_dispatch' && inputs.attest) }} + needs: install + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repository-distribution-${{ github.sha }} + path: target/distribution/release-assets + - name: Generate GitHub/Sigstore provenance for every release asset + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: target/distribution/release-assets/* + + draft-release: + name: Create draft GitHub Release + if: ${{ github.ref_type == 'tag' }} + needs: attest-release-assets + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Require immutable releases before creating a draft + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${GITHUB_REPOSITORY}/immutable-releases" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --jq 'select(.enabled == true) | .enabled' | grep -qx true + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: repository-distribution-${{ github.sha }} + path: target/distribution/release-assets + - name: Create draft release with complete assets + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + args=( + "${GITHUB_REF_NAME}" + target/distribution/release-assets/* + --repo "${GITHUB_REPOSITORY}" + --draft + --verify-tag + --title "PliegoCSS ${GITHUB_REF_NAME}" + --generate-notes + ) + if [[ "${GITHUB_REF_NAME}" == *-* ]]; then + args+=(--prerelease) + fi + gh release create "${args[@]}" diff --git a/.gitignore b/.gitignore index b9df146..461e74d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /benchmarks/results/pliego-gate-b.local.json /benchmarks/results/wasm-overhead.json /benchmarks/pliegors-integration/runtime/ +/benchmarks/external-adoption-v1/private/ /integration-tests/pliegors-smoke/target/ /integration-tests/pliegors-smoke/.pliegocss-gate/ /integration-tests/pliegors-dev-loop/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d41377..e9c42c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,54 @@ pre-1.0 compatibility policy. ## [Unreleased] +## [0.1.0-rc.3] - TBD + +Candidate contents are prepared below. The date remains unset until the exact +source commit passes the required hosted gates and publication is authorized. + +### Added + +- Add the G5 repository-only native distribution pipeline: Windows x64, Linux + x64 GNU, and macOS arm64 CLI/LSP archives; CycloneDX SBOMs; SHA-256 + manifests; GitHub/Sigstore attestations; and a universal no-lifecycle pnpm + package that is never published to npmjs. + +### Changed + +- Separate stable breakpoint identity from narrow-to-wide cascade rank. ThemeId + and theme-binary formats move to 3, catalog to schema 4, and inspection to + schema 3; older generated theme/identity artifacts must be regenerated. +- Namespace official Cargo targets by complete Rust toolchain/platform identity, + including the Rust 1.85 property/LSP gates and Rust 1.86 PliegoRS gate. +- Re-pin the fail-closed PliegoRS integration contract to current `main` after + the G1 native runtime entered the workspace, and replay the complete SSG, + route/island bundle, manifest, and browser-payload gate against that revision. +- Make release-readiness schema 3 the single current promotion authority, + fail closed on unconfigured hosted browser lanes, archive superseded status + reports, and generate the public documentation catalog from hash-bound + Markdown instead of duplicated Rust page data. +- Introduce the pure `CompileRequest`/`CompileResult` engine boundary with a + theme-bound `AnalysisHost`, compiler-owned `PhysicalRulePlanner`, and shared + `pcx` frontend. CLI, watch, macros, source scanning, and LSP now reuse that + engine; the LSP no longer launches compiler children or writes synthetic + source files. +- Replace the handwritten Gate B comparison with Benchmark Authority schema 2: + live `tailwind-latest` 4.3.3, upstream `v3-lts` 3.4.19, frozen 4.3.2 regression, + alternating paired samples, direct-process peak memory, raw/gzip/Brotli output, + micro/medium/large corpora, a seven-day oracle, and JSON-generated benchmark docs. + +### Fixed + +- Emit custom media and container breakpoints by numeric cascade rank instead + of name-assigned `BreakpointId`; the 52rem/72rem regression is frozen in + property tests and Chrome/Edge computed-style gates. +- Reject empty reversible migration projects with a typed public error instead + of indexing an empty inventory, and audit every current Lightning CSS rule + variant without a silent wildcard. +- Keep release-readiness honest after RC.2 evidence expiration, preserve the + intentional removal of the superseded execution plan, and support native + pnpm executables in the VS Code packaging gate. + ## [0.1.0-rc.2] - 2026-07-21 This candidate supersedes `0.1.0-rc.1` without moving its immutable tag. It is @@ -16,6 +64,15 @@ compatibility unit. ### Added +- Browser/output certification v1 against the fresh Tailwind authority lane: 52 computed + properties, 1/64-pixel box/text-line geometry bounded to 0.1 CSS px, bounded perceptual screenshots, + responsive/hover/focus scenarios, and explicit + no-reset/shared-reset contracts. +- A clean, seven-day, commit/tree-bound 3×3 Playwright matrix for Chromium, Firefox, and WebKit on + Windows x64, Linux x64, and macOS ARM64, plus fail-closed aggregation and hashed PNG/JSON evidence. + Playwright 1.61.1, pixelmatch 7.2.0, and pngjs 7.0.0 are pinned and attributed. Dirty local + Windows x64 and Linux x64 passes are diagnostic; hosted 9/9 acceptance remains pending. + - A complete bilingual public-preview website authored with PliegoRS and styled by PliegoCSS, including interactive compiler examples, categorized documentation, command search, legal and accessibility pages, GSAP/Lenis diff --git a/Cargo.lock b/Cargo.lock index ec79a7a..95921d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,7 +397,7 @@ dependencies = [ [[package]] name = "pliego-css" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-macros", @@ -406,7 +406,7 @@ dependencies = [ [[package]] name = "pliego-css-agent" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-build", "pliego-css-config", @@ -417,14 +417,14 @@ dependencies = [ [[package]] name = "pliego-css-basic-example" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css", ] [[package]] name = "pliego-css-build" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "cssparser", "lightningcss", @@ -441,7 +441,7 @@ dependencies = [ [[package]] name = "pliego-css-cascade" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "cssparser", "lightningcss", @@ -451,7 +451,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "pliego-css-config" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "cssparser", "libc", @@ -477,7 +477,7 @@ dependencies = [ [[package]] name = "pliego-css-control" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "cssparser", "lightningcss", @@ -493,7 +493,7 @@ dependencies = [ [[package]] name = "pliego-css-custom-theme-example" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css", "pliego-css-build", @@ -501,22 +501,26 @@ dependencies = [ [[package]] name = "pliego-css-io" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", ] [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-lsp" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ + "pliego-css-compiler", + "pliego-css-config", "pliego-css-io", + "pliego-css-ir", "pliego-css-parser", "pliego-css-source", + "pliego-css-theme", "serde", "serde_json", "sha2", @@ -524,7 +528,7 @@ dependencies = [ [[package]] name = "pliego-css-macros" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-compiler", "pliego-css-ir", @@ -537,7 +541,7 @@ dependencies = [ [[package]] name = "pliego-css-ownership" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "serde", "serde_json", @@ -546,14 +550,14 @@ dependencies = [ [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] [[package]] name = "pliego-css-publication" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "fs2", "libc", @@ -562,7 +566,7 @@ dependencies = [ [[package]] name = "pliego-css-source" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", "pliego-css-io", @@ -575,7 +579,7 @@ dependencies = [ [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", @@ -583,7 +587,7 @@ dependencies = [ [[package]] name = "pliego-css-usage" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-build", "pliego-css-compiler", @@ -596,7 +600,7 @@ dependencies = [ [[package]] name = "pliego-css-watch" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "inotify", "pliego-css-io", @@ -605,7 +609,7 @@ dependencies = [ [[package]] name = "pliego-cssc" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", "pliego-css-agent", diff --git a/Cargo.toml b/Cargo.toml index 007d749..ac1dddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ ] [workspace.package] -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" edition = "2024" rust-version = "1.85" license = "Apache-2.0" @@ -38,24 +38,24 @@ keywords = ["css", "rust", "static-analysis", "pliegors", "web"] categories = ["compilers", "development-tools", "web-programming"] [workspace.dependencies] -pliego-css = { path = "crates/pliego-css", version = "=0.1.0-rc.2" } -pliego-css-agent = { path = "crates/pliego-css-agent", version = "=0.1.0-rc.2" } -pliego-css-build = { path = "crates/pliego-css-build", version = "=0.1.0-rc.2" } -pliego-css-cascade = { path = "crates/pliego-css-cascade", version = "=0.1.0-rc.2" } -pliego-css-compiler = { path = "crates/pliego-css-compiler", version = "=0.1.0-rc.2" } -pliego-css-config = { path = "crates/pliego-css-config", version = "=0.1.0-rc.2" } -pliego-css-control = { path = "crates/pliego-css-control", version = "=0.1.0-rc.2" } -pliego-css-ir = { path = "crates/pliego-css-ir", version = "=0.1.0-rc.2" } -pliego-css-io = { path = "crates/pliego-css-io", version = "=0.1.0-rc.2" } -pliego-css-lsp = { path = "crates/pliego-css-lsp", version = "=0.1.0-rc.2" } -pliego-css-macros = { path = "crates/pliego-css-macros", version = "=0.1.0-rc.2" } -pliego-css-ownership = { path = "crates/pliego-css-ownership", version = "=0.1.0-rc.2" } -pliego-css-publication = { path = "crates/pliego-css-publication", version = "=0.1.0-rc.2" } -pliego-css-parser = { path = "crates/pliego-css-parser", version = "=0.1.0-rc.2" } -pliego-css-source = { path = "crates/pliego-css-source", version = "=0.1.0-rc.2" } -pliego-css-theme = { path = "crates/pliego-css-theme", version = "=0.1.0-rc.2" } -pliego-css-usage = { path = "crates/pliego-css-usage", version = "=0.1.0-rc.2" } -pliego-css-watch = { path = "crates/pliego-css-watch", version = "=0.1.0-rc.2" } +pliego-css = { path = "crates/pliego-css", version = "=0.1.0-rc.3" } +pliego-css-agent = { path = "crates/pliego-css-agent", version = "=0.1.0-rc.3" } +pliego-css-build = { path = "crates/pliego-css-build", version = "=0.1.0-rc.3" } +pliego-css-cascade = { path = "crates/pliego-css-cascade", version = "=0.1.0-rc.3" } +pliego-css-compiler = { path = "crates/pliego-css-compiler", version = "=0.1.0-rc.3" } +pliego-css-config = { path = "crates/pliego-css-config", version = "=0.1.0-rc.3" } +pliego-css-control = { path = "crates/pliego-css-control", version = "=0.1.0-rc.3" } +pliego-css-ir = { path = "crates/pliego-css-ir", version = "=0.1.0-rc.3" } +pliego-css-io = { path = "crates/pliego-css-io", version = "=0.1.0-rc.3" } +pliego-css-lsp = { path = "crates/pliego-css-lsp", version = "=0.1.0-rc.3" } +pliego-css-macros = { path = "crates/pliego-css-macros", version = "=0.1.0-rc.3" } +pliego-css-ownership = { path = "crates/pliego-css-ownership", version = "=0.1.0-rc.3" } +pliego-css-publication = { path = "crates/pliego-css-publication", version = "=0.1.0-rc.3" } +pliego-css-parser = { path = "crates/pliego-css-parser", version = "=0.1.0-rc.3" } +pliego-css-source = { path = "crates/pliego-css-source", version = "=0.1.0-rc.3" } +pliego-css-theme = { path = "crates/pliego-css-theme", version = "=0.1.0-rc.3" } +pliego-css-usage = { path = "crates/pliego-css-usage", version = "=0.1.0-rc.3" } +pliego-css-watch = { path = "crates/pliego-css-watch", version = "=0.1.0-rc.3" } [workspace.lints.rust] missing_docs = "warn" diff --git a/README.md b/README.md index ba2c6d9..111f600 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,10 @@

> [!IMPORTANT] -> `0.1.0-rc.2` is public-preview software distributed from the public -> canonical repository. +> This workspace prepares `0.1.0-rc.3` as the next public-preview candidate. > The nineteen PliegoCSS crates form one exact-version compatibility unit and -> are published on crates.io and replayed from the registry with Rust 1.85 -> after the complete hosted CI and CodeQL matrices passed. `0.1.0` remains blocked by the +> must not be mixed across versions. RC.3 publication and registry replay remain +> blocked until the exact-source hosted gates pass. `0.1.0` remains blocked by the > [machine readiness record](./docs/product/release-readiness-0.1.0.md). > Core formats and selected APIs are frozen candidates; beta and experimental > capabilities may still change. @@ -76,7 +75,7 @@ PliegoCSS is designed around four principles: The exact maturity and claim boundaries are maintained in the [product maturity map](./docs/product/maturity-map.md). Competitive coverage is tracked honestly in -the [Tailwind CSS v4.3.2 matrix](./docs/benchmarks/tailwind-v4-competitive-matrix.md). +the [Tailwind CSS v4.3.3 matrix](./docs/benchmarks/tailwind-v4-competitive-matrix.md). ## Quick start @@ -84,14 +83,20 @@ the [Tailwind CSS v4.3.2 matrix](./docs/benchmarks/tailwind-v4-competitive-matri - Rust **1.85 or newer** - Git -- Node.js and pnpm are required only for repository development and full verification +- Node.js and pnpm are required for repository development/full verification and for the optional + repository-hosted pnpm launcher; they are not Rust crate dependencies -Install the public-preview CLI from crates.io: +After RC.3 is published, install the exact CLI from crates.io: ```console -cargo install pliego-cssc --version '=0.1.0-rc.2' --locked +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked ``` +RC.3 is designed to attach a repository-only universal pnpm package to its immutable GitHub +Release. It is never published to npmjs, contains no dependencies or lifecycle scripts, and must be +downloaded, verified, and installed as a local `.tgz` with pnpm. RC.2 predates that G5 artifact; see +the [installation guide](./docs/getting-started/installation.md#repository-hosted-pnpm-package). + Or clone the repository and build locally: ```console diff --git a/SECURITY.md b/SECURITY.md index 17f338e..78aca1b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,8 @@ and the default branch. | Version | Supported | | --- | --- | -| `0.1.0-rc.2` | Yes | +| `0.1.0-rc.3` candidate and `main` | Yes | +| `0.1.0-rc.2` | No | | `0.1.0-rc.1` | No | | Earlier snapshots | No | diff --git a/SUPPORT.md b/SUPPORT.md index badee16..63680ce 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -7,6 +7,8 @@ Read the [project documentation](docs/index.md), CLI help, and [troubleshooting guides](docs/troubleshooting/lsp.md) before opening a request. Include stable diagnostic identifiers whenever one is emitted. +The exact certified adapter/version matrix and explicit exclusions are published in the +[0.1.x adapter support policy](docs/reference/adapter-support-policy-0.1.x.md). ## Public support @@ -24,5 +26,9 @@ General Rust or CSS consulting, private-project debugging, and requests without a reproduction may be redirected or closed. Use `hello@pliegocss.dev` for private project correspondence. +External teams interested in a consented G7 interview or bounded adoption pilot should use the same +address and review the [evidence intake protocol](docs/contributing/external-adoption-intake.md). +The public cohort notice is [GitHub issue #7](https://github.com/celiumsai/pliegocss/issues/7); +public comments are recruitment signals only and must not contain private evidence or personal data. Vulnerabilities must follow [SECURITY.md](SECURITY.md). PliegoCSS is provided under Apache-2.0 without a support SLA or warranty. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2c6f03a..17714ca 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -64,12 +64,41 @@ bundled software dependencies. ## Benchmark-only software -### Tailwind CSS and CLI 4.3.2 +### Tailwind CSS and CLI benchmark lanes -- Relationship: frozen performance and ergonomics baseline only; not a PliegoCSS runtime/backend - dependency. +- Relationship: performance and ergonomics baselines only; not PliegoCSS runtime/backend + dependencies. Benchmark Authority v2 uses current `4.3.3`, upstream `v3-lts` `3.4.19`, and + frozen regression control `4.3.2`. - License: MIT. - Source: https://github.com/tailwindlabs/tailwindcss -- Locked evidence: `tailwindcss@4.3.2`, `@tailwindcss/cli@4.3.2`, and the complete transitive - `pnpm-lock.yaml` graph at SHA-256 - `c4f9b91705418524bd91178fd417e3b02585dfce613c2a56e6cef25a6c8c1049`. +- Locked evidence: `tailwindcss@4.3.3`, `@tailwindcss/cli@4.3.3`, + `tailwindcss@3.4.19`, `tailwindcss@4.3.2`, `@tailwindcss/cli@4.3.2`, their registry SRI values, + and the complete transitive `pnpm-lock.yaml` graph at SHA-256 + `b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51`. + +## Browser-certification software + +These packages are test-only tooling for Browser/output certification v1. They do not ship in the +PliegoCSS compiler or generated CSS. + +### Playwright 1.61.1 + +- Relationship: launches the pinned Chromium, Firefox, and WebKit certification engines. +- License: Apache-2.0. +- Source: https://github.com/microsoft/playwright +- Locked evidence: `playwright@1.61.1`, `playwright-core@1.61.1`, their npm SRI values, and the + complete `pnpm-lock.yaml` SHA-256 recorded above. + +### pixelmatch 7.2.0 + +- Relationship: bounded perceptual comparison of captured PNG pixels. +- License: ISC. +- Source: https://github.com/mapbox/pixelmatch +- Locked evidence: `pixelmatch@7.2.0`, its npm SRI, and the complete pnpm lock SHA-256. + +### pngjs 7.0.0 + +- Relationship: deterministic PNG decoding and diff encoding for certification evidence. +- License: MIT. +- Source: https://github.com/pngjs/pngjs +- Locked evidence: `pngjs@7.0.0`, its npm SRI, and the complete pnpm lock SHA-256. diff --git a/benchmarks/adapter-coexistence-v1/authority.json b/benchmarks/adapter-coexistence-v1/authority.json new file mode 100644 index 0000000..f1e8c22 --- /dev/null +++ b/benchmarks/adapter-coexistence-v1/authority.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "status": "active-contract", + "kind": "pliegocss-adapter-coexistence-certification", + "corpus": { + "path": "benchmarks/adapter-coexistence-v1/corpus.json", + "sha256": "2d569964591d4a03981c2762cdeda3daa99a5323f1fe97460b74afa85e878a8c", + "minimumProjects": 20 + }, + "sharedReset": { + "path": "benchmarks/browser-output-certification-v1/shared-reset.css", + "sha256": "5d190d599bf4b966fd76cf1c456b5904c8135506c9a6a4e4853ac4fc6cac6f38" + }, + "tailwindProfiles": [ + { + "id": "tailwind-v3-lts", + "packageAlias": "tailwindcss-v3-lts", + "packageName": "tailwindcss", + "version": "3.4.19", + "sourceContract": "v3-directives-no-preflight" + }, + { + "id": "tailwind-v4-current", + "packageAlias": "tailwindcss", + "cliPackageAlias": "@tailwindcss/cli", + "packageName": "tailwindcss", + "version": "4.3.3", + "sourceContract": "v4-import-no-preflight" + } + ], + "adapters": [ + { + "id": "html", + "minimumProjects": 7, + "execution": "direct-document" + }, + { + "id": "vite", + "minimumProjects": 7, + "execution": "vite-production-build", + "package": "vite", + "version": "8.1.5" + }, + { + "id": "pliegors", + "minimumProjects": 7, + "execution": "rendered-output-contract", + "upstreamGate": "integration:pliegors-browser" + } + ], + "hosts": [ + { + "id": "windows-x64-chromium", + "runner": "windows-latest", + "os": "win32", + "arch": "x64", + "browser": "chromium" + }, + { + "id": "linux-x64-chromium", + "runner": "ubuntu-24.04", + "os": "linux", + "arch": "x64", + "browser": "chromium" + }, + { + "id": "macos-arm64-chromium", + "runner": "macos-15", + "os": "darwin", + "arch": "arm64", + "browser": "chromium" + } + ], + "migration": { + "mode": "static-complete-class-group", + "applyCommand": "migration-group-apply", + "rollbackCommand": "migration-group-rollback", + "tailwindRuntimeRetained": true, + "dynamicClassPolicy": "reject", + "unsupportedUtilityPolicy": "reject", + "rollback": "restore-exact-source-bytes" + }, + "comparison": { + "dom": "Body element/text tree and every non-class attribute must be identical.", + "aria": "Playwright body ARIA snapshot must be byte-identical.", + "computedStyle": "Every frozen property on each migrated class-bearing node must be equal after color and browser alias normalization.", + "layoutGeometry": "Each migrated node border box may differ by at most 0.1 CSS px.", + "maximumLayoutGeometryDeltaCssPx": 0.1, + "computedProperties": [ + "display", + "position", + "box-sizing", + "width", + "height", + "min-width", + "min-height", + "max-width", + "max-height", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "row-gap", + "column-gap", + "align-items", + "justify-content", + "flex-direction", + "flex-wrap", + "flex-grow", + "flex-shrink", + "grid-template-columns", + "color", + "background-color", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "border-top-style", + "border-top-color", + "border-radius", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "font-family", + "font-size", + "font-weight", + "line-height", + "letter-spacing", + "text-transform", + "opacity", + "cursor", + "resize" + ] + }, + "requiredCoverage": [ + "at-least-20-projects", + "html", + "vite-production-build", + "pliegors-rendered-output", + "pinned-pliegors-framework-browser", + "tailwind-v3-lts", + "tailwind-v4-current", + "tailwind-source-inventory", + "tailwind-output-audit", + "reversible-group-apply", + "exact-byte-rollback", + "dom-equivalence", + "aria-equivalence", + "computed-style-equivalence", + "layout-geometry-equivalence" + ], + "claimBoundary": "Passing proves the bounded, static complete-class-group migration protocol for the tracked corpus and named hosts. Tailwind remains loaded during coexistence. Dynamic class construction, arbitrary plugin execution, general Tailwind compatibility, and untracked PliegoRS applications are outside this claim." +} diff --git a/benchmarks/adapter-coexistence-v1/corpus.json b/benchmarks/adapter-coexistence-v1/corpus.json new file mode 100644 index 0000000..97e32c4 --- /dev/null +++ b/benchmarks/adapter-coexistence-v1/corpus.json @@ -0,0 +1,174 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-adapter-coexistence-corpus", + "projects": [ + { + "id": "html-action-buttons", + "adapter": "html", + "tailwindProfile": "tailwind-v3-lts", + "title": "Action buttons", + "viewport": { "width": 390, "height": 844 }, + "body": "

Publish controls

" + }, + { + "id": "html-editorial-card", + "adapter": "html", + "tailwindProfile": "tailwind-v4-current", + "title": "Editorial card", + "viewport": { "width": 768, "height": 900 }, + "body": "

Edition 006

Architecture as evidence

A bounded card with semantic heading and descriptive copy.

" + }, + { + "id": "html-primary-navigation", + "adapter": "html", + "tailwindProfile": "tailwind-v3-lts", + "title": "Primary navigation", + "viewport": { "width": 1440, "height": 900 }, + "body": "
" + }, + { + "id": "html-project-form", + "adapter": "html", + "tailwindProfile": "tailwind-v4-current", + "title": "Project form", + "viewport": { "width": 1024, "height": 900 }, + "body": "

Create project

" + }, + { + "id": "html-build-metrics", + "adapter": "html", + "tailwindProfile": "tailwind-v3-lts", + "title": "Build metrics", + "viewport": { "width": 1280, "height": 800 }, + "body": "

Build metrics

Build time

12 ms

CSS gzip

4.2 KB

" + }, + { + "id": "html-release-article", + "adapter": "html", + "tailwindProfile": "tailwind-v4-current", + "title": "Release article", + "viewport": { "width": 820, "height": 1000 }, + "body": "

Release candidate

Immutable by default

Every promoted artifact stays bound to its source commit.

Read notes
" + }, + { + "id": "html-status-alert", + "adapter": "html", + "tailwindProfile": "tailwind-v3-lts", + "title": "Status alert", + "viewport": { "width": 600, "height": 700 }, + "body": "

All systems operational

Release evidence is current

Verified on the selected source commit.

" + }, + { + "id": "vite-sign-in", + "adapter": "vite", + "tailwindProfile": "tailwind-v4-current", + "title": "Vite sign in", + "viewport": { "width": 430, "height": 900 }, + "body": "

Sign in

" + }, + { + "id": "vite-pricing-options", + "adapter": "vite", + "tailwindProfile": "tailwind-v3-lts", + "title": "Vite pricing", + "viewport": { "width": 1100, "height": 800 }, + "body": "

Choose a plan

Community

Free

LTS

Stable

" + }, + { + "id": "vite-runtime-settings", + "adapter": "vite", + "tailwindProfile": "tailwind-v4-current", + "title": "Vite settings", + "viewport": { "width": 900, "height": 850 }, + "body": "

Runtime settings

" + }, + { + "id": "vite-profile-summary", + "adapter": "vite", + "tailwindProfile": "tailwind-v3-lts", + "title": "Vite profile", + "viewport": { "width": 760, "height": 760 }, + "body": "

Founder

Mario Gutierrez

Building deterministic systems from Medellin.

" + }, + { + "id": "vite-document-search", + "adapter": "vite", + "tailwindProfile": "tailwind-v4-current", + "title": "Vite search", + "viewport": { "width": 960, "height": 720 }, + "body": "

Search documentation

" + }, + { + "id": "vite-build-table", + "adapter": "vite", + "tailwindProfile": "tailwind-v3-lts", + "title": "Vite build table", + "viewport": { "width": 1200, "height": 780 }, + "body": "

Build matrix

Windows

Passed

Linux

Passed

" + }, + { + "id": "vite-empty-state", + "adapter": "vite", + "tailwindProfile": "tailwind-v4-current", + "title": "Vite empty state", + "viewport": { "width": 700, "height": 700 }, + "body": "

No releases yet

Draft a release and attach every artifact before publication.

" + }, + { + "id": "pliegors-release-hero", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v3-lts", + "title": "PliegoRS hero", + "viewport": { "width": 1440, "height": 900 }, + "body": "

PliegoRS rendered

One typed deployment graph

Server output preserves semantic markup while styles migrate.

Open docs
" + }, + { + "id": "pliegors-counter-island", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v4-current", + "title": "PliegoRS counter island", + "viewport": { "width": 480, "height": 720 }, + "body": "

Resumable island

Counter: 0

" + }, + { + "id": "pliegors-docs-navigation", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v3-lts", + "title": "PliegoRS docs navigation", + "viewport": { "width": 1024, "height": 768 }, + "body": "
" + }, + { + "id": "pliegors-host-status", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v4-current", + "title": "PliegoRS host status", + "viewport": { "width": 760, "height": 800 }, + "body": "

Hosted adapter status

Source

Exact

Rollback

Verified

" + }, + { + "id": "pliegors-control-dashboard", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v3-lts", + "title": "PliegoRS dashboard", + "viewport": { "width": 1366, "height": 900 }, + "body": "

Control dashboard

Receipts

21

Bound

ARIA drift

0

Equivalent

" + }, + { + "id": "pliegors-confirm-dialog", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v4-current", + "title": "PliegoRS dialog", + "viewport": { "width": 640, "height": 720 }, + "body": "

Publish immutable release?

Assets cannot be replaced after publication.

" + }, + { + "id": "pliegors-pagination", + "adapter": "pliegors", + "tailwindProfile": "tailwind-v4-current", + "title": "PliegoRS pagination", + "viewport": { "width": 900, "height": 650 }, + "body": "

Releases

" + } + ] +} diff --git a/benchmarks/benchmark-authority-v2/micro.html b/benchmarks/benchmark-authority-v2/micro.html new file mode 100644 index 0000000..df4c57c --- /dev/null +++ b/benchmarks/benchmark-authority-v2/micro.html @@ -0,0 +1,15 @@ + + + + + + PliegoCSS benchmark authority micro corpus + + + + + diff --git a/benchmarks/benchmark-authority-v2/oracle.json b/benchmarks/benchmark-authority-v2/oracle.json new file mode 100644 index 0000000..fe18209 --- /dev/null +++ b/benchmarks/benchmark-authority-v2/oracle.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 2, + "status": "active", + "observedAtUtc": "2026-08-26T12:40:21.000Z", + "expiresAtUtc": "2026-09-02T12:40:21.000Z", + "maximumAgeHours": 168, + "registry": "https://registry.npmjs.org", + "primaryLane": "tailwind-latest", + "resetContract": "no-preflight", + "lanes": [ + { + "id": "tailwind-latest", + "role": "current-competitor", + "distTag": "latest", + "packageAlias": "tailwindcss", + "packageName": "tailwindcss", + "version": "4.3.3", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "cliPackageAlias": "@tailwindcss/cli", + "cliPackageName": "@tailwindcss/cli", + "cliVersion": "4.3.3", + "cliIntegrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==" + }, + { + "id": "tailwind-v3-lts", + "role": "supported-historical-major", + "distTag": "v3-lts", + "packageAlias": "tailwindcss-v3-lts", + "packageName": "tailwindcss", + "version": "3.4.19", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "cliPackageAlias": "tailwindcss-v3-lts", + "cliPackageName": "tailwindcss", + "cliVersion": "3.4.19", + "cliIntegrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==" + }, + { + "id": "tailwind-frozen-release", + "role": "historical-regression-control", + "distTag": null, + "packageAlias": "tailwindcss-frozen", + "packageName": "tailwindcss", + "version": "4.3.2", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "cliPackageAlias": "@tailwindcss/cli-frozen", + "cliPackageName": "@tailwindcss/cli", + "cliVersion": "4.3.2", + "cliIntegrity": "sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw==" + } + ], + "corpora": [ + { + "id": "micro", + "source": "benchmarks/benchmark-authority-v2/micro.html", + "materialization": "tracked", + "repeat": 1, + "scope": "one representative interactive component", + "expected": { + "sha256": "4ccff6c4c638a374b81420ff4f0919cfc5b4ee67d5a060bbf7ff9caf5b34aa5c", + "htmlBytes": 682, + "classAttributeCount": 2, + "utilityOccurrences": 25, + "uniqueUtilityTokens": 25, + "uniqueStyleInputs": 2 + } + }, + { + "id": "medium", + "source": "benchmarks/tailwind-v4/fixtures.html", + "materialization": "tracked", + "repeat": 1, + "scope": "five representative views", + "expected": { + "sha256": "338eb480327e54fe7e56e81b42c67324464afaa8c65d606e5d57c1c45690e86c", + "htmlBytes": 5868, + "classAttributeCount": 44, + "utilityOccurrences": 302, + "uniqueUtilityTokens": 90, + "uniqueStyleInputs": 31 + } + }, + { + "id": "large", + "source": "benchmarks/tailwind-v4/fixtures.html", + "materialization": "repeat-body", + "repeat": 32, + "scope": "source-volume stress corpus; utility vocabulary intentionally matches medium", + "expected": { + "sha256": "219629e1ce54db38395efa15f800faa75c2b67624ad688fdf8b37a36019ecf85", + "htmlBytes": 179981, + "classAttributeCount": 1408, + "utilityOccurrences": 9664, + "uniqueUtilityTokens": 90, + "uniqueStyleInputs": 31 + } + } + ], + "methodology": { + "design": "paired-interleaved-alternating-order", + "warmupPairs": 5, + "measuredPairs": 30, + "pairOrder": "odd pairs run PliegoCSS then Tailwind; even pairs run Tailwind then PliegoCSS", + "freshProcess": true, + "freshOutputVerification": true, + "completeSamples": true, + "memoryMetric": "direct child-process peak working set bytes sampled by the versioned native runner", + "compression": { + "gzipLevel": 9, + "brotliQuality": 11 + }, + "scorePolicy": "No competitive score is valid without a non-expired oracle and immutable schema-2 evidence from a clean source tree." + }, + "artifacts": { + "localResult": "benchmarks/results/benchmark-authority-v2.local.json", + "evidenceRoot": "benchmarks/evidence/v2", + "generatedDocumentation": "docs/benchmarks/tailwind-benchmark-authority-v2.md" + } +} diff --git a/benchmarks/browser-output-certification-v1/authority.json b/benchmarks/browser-output-certification-v1/authority.json new file mode 100644 index 0000000..3643219 --- /dev/null +++ b/benchmarks/browser-output-certification-v1/authority.json @@ -0,0 +1,119 @@ +{ + "schemaVersion": 1, + "status": "active-contract", + "kind": "pliegocss-browser-output-certification", + "competitorAuthority": "benchmarks/benchmark-authority-v2/oracle.json", + "competitorLane": "tailwind-latest", + "maximumEvidenceAgeHours": 168, + "fixture": { + "path": "benchmarks/tailwind-v4/fixtures.html", + "sha256": "338eb480327e54fe7e56e81b42c67324464afaa8c65d606e5d57c1c45690e86c" + }, + "reset": { + "path": "benchmarks/browser-output-certification-v1/shared-reset.css", + "sha256": "5d190d599bf4b966fd76cf1c456b5904c8135506c9a6a4e4853ac4fc6cac6f38" + }, + "resetModes": [ + { + "id": "no-reset", + "contract": "Neither PliegoCSS nor Tailwind receives Preflight or any external reset." + }, + { + "id": "shared-reset", + "contract": "The exact tracked shared reset bytes are prepended to both outputs." + } + ], + "browsers": ["chromium", "firefox", "webkit"], + "hosts": [ + { "id": "windows-x64-chromium", "runner": "windows-latest", "os": "win32", "arch": "x64", "browser": "chromium" }, + { "id": "windows-x64-firefox", "runner": "windows-latest", "os": "win32", "arch": "x64", "browser": "firefox" }, + { "id": "windows-x64-webkit", "runner": "windows-latest", "os": "win32", "arch": "x64", "browser": "webkit" }, + { "id": "linux-x64-chromium", "runner": "ubuntu-24.04", "os": "linux", "arch": "x64", "browser": "chromium" }, + { "id": "linux-x64-firefox", "runner": "ubuntu-24.04", "os": "linux", "arch": "x64", "browser": "firefox" }, + { "id": "linux-x64-webkit", "runner": "ubuntu-24.04", "os": "linux", "arch": "x64", "browser": "webkit" }, + { "id": "macos-arm64-chromium", "runner": "macos-15", "os": "darwin", "arch": "arm64", "browser": "chromium" }, + { "id": "macos-arm64-firefox", "runner": "macos-15", "os": "darwin", "arch": "arm64", "browser": "firefox" }, + { "id": "macos-arm64-webkit", "runner": "macos-15", "os": "darwin", "arch": "arm64", "browser": "webkit" } + ], + "computedProperties": [ + "display", + "position", + "box-sizing", + "width", + "height", + "min-width", + "min-height", + "max-width", + "max-height", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "row-gap", + "column-gap", + "align-items", + "justify-content", + "justify-self", + "flex-direction", + "flex-wrap", + "flex-grow", + "flex-shrink", + "grid-template-columns", + "grid-column-start", + "grid-column-end", + "color", + "background-color", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "border-top-style", + "border-top-color", + "border-radius", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "font-family", + "font-size", + "font-weight", + "line-height", + "letter-spacing", + "text-transform", + "text-rendering", + "opacity", + "cursor", + "resize" + ], + "scenarios": [ + { "id": "base-mobile", "width": 375, "height": 812, "action": "none" }, + { "id": "base-tablet", "width": 768, "height": 1024, "action": "none" }, + { "id": "base-desktop", "width": 1440, "height": 900, "action": "none" }, + { "id": "hover-primary", "width": 1440, "height": 900, "action": "hover", "selector": "[data-fixture=button] button:first-of-type" }, + { "id": "focus-input", "width": 1440, "height": 900, "action": "focus", "selector": "input" } + ], + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "comparison": { + "computedStyle": "Equality for every frozen property after 8-bit sRGB color normalization, flex-end alias normalization, and quarter-CSS-pixel layout quantization within one browser host.", + "computedLengthQuantumCssPx": 0.25, + "layoutGeometry": "Every styled node border box, client/scroll extent, and direct non-whitespace text-node client rect is captured at 1/64 CSS px; corresponding numeric values may differ by at most 0.1 CSS px within one browser host.", + "layoutGeometryResolutionCssPx": 0.015625, + "maximumLayoutGeometryDeltaCssPx": 0.1, + "screenshotPixelThreshold": 0.2, + "maximumScreenshotMismatchRatio": 0.005, + "screenshots": "Perceptual PNG comparison with antialias classification, threshold 0.2, and at most 0.5% mismatched CSS pixels within one browser host and scenario after computed style and geometry pass; original PNG hashes remain retained.", + "claimBoundary": "Passing proves the frozen shared utility intent in the named browser/OS/architecture host. It is not general Tailwind compatibility or cross-browser pixel identity." + } +} diff --git a/benchmarks/browser-output-certification-v1/shared-reset.css b/benchmarks/browser-output-certification-v1/shared-reset.css new file mode 100644 index 0000000..1476c1b --- /dev/null +++ b/benchmarks/browser-output-certification-v1/shared-reset.css @@ -0,0 +1,59 @@ +@layer reset { +*, +*::before, +*::after { + box-sizing: border-box; + border-width: 0; + border-style: solid; + border-color: currentColor; +} + +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; +} + +body, +h1, +h2, +p { + margin: 0; +} + +button, +input, +optgroup, +select, +textarea { + margin: 0; + padding: 0; + color: inherit; + font: inherit; + letter-spacing: inherit; +} + +button, +select { + text-transform: none; +} + +button, +input:where([type="button"], [type="reset"], [type="submit"]) { + appearance: button; + background-color: transparent; + background-image: none; +} + +textarea { + resize: vertical; +} + +button, +[role="button"] { + cursor: pointer; +} + +:disabled { + cursor: default; +} +} diff --git a/benchmarks/evidence/README.md b/benchmarks/evidence/README.md index 3747774..016a5ca 100644 --- a/benchmarks/evidence/README.md +++ b/benchmarks/evidence/README.md @@ -16,6 +16,7 @@ Generate a new file; never overwrite an existing snapshot: node scripts/measure-pliego-gate-a.mjs --evidence benchmarks/evidence/.json node scripts/measure-pliego-gate-b.mjs --evidence benchmarks/evidence/.json node scripts/measure-rust-check.mjs --evidence benchmarks/evidence/.json +node scripts/measure-benchmark-authority-v2.mjs --evidence=benchmarks/evidence/v2/.json ``` If an audit supersedes a snapshot, remove it from the current approved set without editing its @@ -35,6 +36,11 @@ The verifier needs Git history for the recorded commits. It recomputes statistic comparison arithmetic, and every paired Rust delta; it does not rerun performance measurements. The approved filename/SHA-256 set is explicit in the verifier, so adding or replacing evidence requires an intentional review of both the immutable snapshot and its allowlist entry. + +Benchmark Authority v2 evidence is a separate schema-2 family. It requires a non-expired live +oracle, canonical 5/30 pairs, a clean tree, a new path below `benchmarks/evidence/v2`, direct-process +peak memory, raw/gzip/Brotli output, and complete samples for all three lanes and corpora. Until a +snapshot is reviewed and allowlisted, machine-local v2 output does not authorize a competitive score. Evidence-mode harnesses also require every recorded harness, fixture, and lock input to match the exact Git blob at the clean source commit; Git's clean status alone is not treated as byte identity. Once evidence is approved, its recorded source commit must remain reachable: do not squash, rebase, diff --git a/benchmarks/external-adoption-v1/authority.json b/benchmarks/external-adoption-v1/authority.json new file mode 100644 index 0000000..f6eac76 --- /dev/null +++ b/benchmarks/external-adoption-v1/authority.json @@ -0,0 +1,73 @@ +{ + "schemaVersion": 1, + "status": "active-contract", + "kind": "pliegocss-external-adoption-authority", + "studyId": "g7-external-adoption-0.1", + "recruitment": { + "status": "open", + "openedAt": "2026-07-25T21:21:33Z", + "publicCohortUrl": "https://github.com/celiumsai/pliegocss/issues/7", + "privateContact": "mailto:hello@pliegocss.dev?subject=G7%20external%20adoption", + "privacyNotice": "https://pliegocss.dev/legal/privacy/", + "publicApplicationPolicy": "recruitment-only-no-private-data" + }, + "evidence": { + "interviews": { + "path": "benchmarks/external-adoption-v1/interviews.json", + "sha256": "03cc6813403f440cbda57640c79253d5e23e6edba85bb62f043a9400cea183d7" + }, + "incidents": { + "path": "benchmarks/external-adoption-v1/incidents.json", + "sha256": "133ff12166a776f0a1d2b520b0933fcaca1a28a3c670378567c6d2fda1354a49" + }, + "pilots": { + "path": "benchmarks/external-adoption-v1/pilots.json", + "sha256": "f9cf5d533f27437da73eaf0ab039c4caec3206a133704c956362e022af4192d6" + } + }, + "adapterSupportPolicy": { + "path": "docs/product/adapter-support-policy-0.1.x.json", + "sha256": "46852f1ed0b1bc32fc5ac1e127ca99634db61fa968b3603ee711101905def66a" + }, + "thresholds": { + "interviews": { + "minimumQualified": 10, + "targetMaximum": 15 + }, + "incidents": { + "minimumQualified": 20 + }, + "pilots": { + "minimumQualified": 3, + "targetMaximum": 5, + "minimumIndependentProjects": 3, + "minimumIndependentOrganizations": 3 + } + }, + "eligibility": { + "allowedRelationships": [ + "external-community", + "external-customer", + "external-partner" + ], + "excludedOrganizationIds": [ + "celiums-solutions", + "pliegocss" + ], + "excludedRepositoryOwners": [ + "celiumsai" + ], + "requiredConsentStatus": "recorded", + "requiredReviewStatus": "approved", + "rawEvidencePolicy": "private-custody-hash-public-redacted-record" + }, + "requiredCoverage": [ + "ten-to-fifteen-interviews", + "twenty-incidents", + "three-to-five-pilots", + "three-independent-projects", + "three-independent-organizations", + "frozen-adapter-support-policy" + ], + "claimBoundary": "Only reviewed evidence from projects not owned or maintained by Celiums Solutions, PliegoCSS, or celiumsai counts. Owned fixtures, synthetic incidents, unreviewed notes, unconsented records, duplicate participants/projects, and unhashed private material never satisfy G7." +} diff --git a/benchmarks/external-adoption-v1/incidents.json b/benchmarks/external-adoption-v1/incidents.json new file mode 100644 index 0000000..f1423cd --- /dev/null +++ b/benchmarks/external-adoption-v1/incidents.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-external-adoption-incidents", + "studyId": "g7-external-adoption-0.1", + "records": [] +} diff --git a/benchmarks/external-adoption-v1/interviews.json b/benchmarks/external-adoption-v1/interviews.json new file mode 100644 index 0000000..b5e9bd1 --- /dev/null +++ b/benchmarks/external-adoption-v1/interviews.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-external-adoption-interviews", + "studyId": "g7-external-adoption-0.1", + "records": [] +} diff --git a/benchmarks/external-adoption-v1/pilots.json b/benchmarks/external-adoption-v1/pilots.json new file mode 100644 index 0000000..701d04d --- /dev/null +++ b/benchmarks/external-adoption-v1/pilots.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-external-adoption-pilots", + "studyId": "g7-external-adoption-0.1", + "records": [] +} diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md index 73c2483..ebbe95b 100644 --- a/benchmarks/results/README.md +++ b/benchmarks/results/README.md @@ -4,12 +4,19 @@ Machine-specific measurements use the suffix `.local.json` and are not committed commit snapshots with complete environment metadata belong in `benchmarks/evidence/`; narrative comparisons live under `docs/benchmarks/`. -Run the current Tailwind baseline with: +Run the current competitor authority with: -```shell -pnpm baseline:measure +```console +node scripts/measure-benchmark-authority-v2.mjs ``` +The schema-2 local report is `benchmark-authority-v2.local.json`. It contains paired latency and +peak-working-set samples plus raw/gzip/Brotli output for latest, v3-LTS, and frozen Tailwind lanes +across micro, medium, and large corpora. Legacy Gate A/B local filenames are historical contracts. + +The legacy `pnpm baseline:measure-legacy-v4` command remains an exploratory single-version harness +and is not a current competitive oracle. + Run the deterministic adjacent-media size gate with `pnpm check:media`. Use `pnpm baseline:measure-media` to write the ignored `media-merge.local.json` report. diff --git a/benchmarks/rust-check/locks/styled.Cargo.lock b/benchmarks/rust-check/locks/styled.Cargo.lock index 5c4b16d..9d0cc1e 100644 --- a/benchmarks/rust-check/locks/styled.Cargo.lock +++ b/benchmarks/rust-check/locks/styled.Cargo.lock @@ -71,7 +71,7 @@ checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732" [[package]] name = "pliego-css" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-macros", @@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -89,11 +89,11 @@ dependencies = [ [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-macros" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-compiler", "pliego-css-ir", @@ -106,14 +106,14 @@ dependencies = [ [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", diff --git a/crates/pliego-css-agent/tests/verification_cli.rs b/crates/pliego-css-agent/tests/verification_cli.rs index 585a667..b4f0c4c 100644 --- a/crates/pliego-css-agent/tests/verification_cli.rs +++ b/crates/pliego-css-agent/tests/verification_cli.rs @@ -84,8 +84,27 @@ fn temp_root() -> PathBuf { path } +fn agent_binary() -> PathBuf { + if let Some(path) = std::env::var_os("CARGO_BIN_EXE_pliego-css-agent") { + return PathBuf::from(path); + } + + let mut path = std::env::current_exe().expect("integration test path must be available"); + path.pop(); + if path.ends_with("deps") { + path.pop(); + } + path.push(format!("pliego-css-agent{}", std::env::consts::EXE_SUFFIX)); + assert!( + path.is_file(), + "Cargo-built pliego-css-agent binary is missing at {}", + path.display() + ); + path +} + fn run(root: &Path, policy: &str, receipt: &str) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_pliego-css-agent")) + Command::new(agent_binary()) .current_dir(root) .args([ "verify", @@ -305,7 +324,7 @@ fn fixed_runner_evidence_reaches_verification_without_a_command_policy_surface() ) .unwrap(); - let run_tests = Command::new(env!("CARGO_BIN_EXE_pliego-css-agent")) + let run_tests = Command::new(agent_binary()) .current_dir(&root) .args([ "run-tests", diff --git a/crates/pliego-css-build/src/artifacts/audit.rs b/crates/pliego-css-build/src/artifacts/audit.rs index ad91e81..de2cd1d 100644 --- a/crates/pliego-css-build/src/artifacts/audit.rs +++ b/crates/pliego-css-build/src/artifacts/audit.rs @@ -1212,6 +1212,7 @@ fn outcome( }) } +#[allow(clippy::too_many_lines)] fn inventory_rules( rules: &CssRuleList<'_>, metrics: &mut Metrics, @@ -1221,7 +1222,46 @@ fn inventory_rules( for rule in &rules.0 { metrics.rules += 1; match rule { + CssRule::Import(rule) => { + observe_unclassified(unclassified, "@import", "@import", rule.loc); + } CssRule::Style(rule) => inventory_style(rule, metrics, features, unclassified), + CssRule::Keyframes(rule) => { + observe_unclassified(unclassified, "@keyframes", "@keyframes", rule.loc); + } + CssRule::FontFace(rule) => { + observe_unclassified(unclassified, "@font-face", "@font-face", rule.loc); + } + CssRule::FontPaletteValues(rule) => observe_unclassified( + unclassified, + "@font-palette-values", + "@font-palette-values", + rule.loc, + ), + CssRule::FontFeatureValues(rule) => observe_unclassified( + unclassified, + "@font-feature-values", + "@font-feature-values", + rule.loc, + ), + CssRule::Page(rule) => { + observe_unclassified(unclassified, "@page", "@page", rule.loc); + } + CssRule::Media(rule) => { + inventory_rules(&rule.rules, metrics, features, unclassified); + } + CssRule::Supports(rule) => { + inventory_rules(&rule.rules, metrics, features, unclassified); + } + CssRule::CounterStyle(rule) => { + observe_unclassified(unclassified, "@counter-style", "@counter-style", rule.loc); + } + CssRule::Namespace(rule) => { + observe_unclassified(unclassified, "@namespace", "@namespace", rule.loc); + } + CssRule::MozDocument(rule) => { + inventory_rules(&rule.rules, metrics, features, unclassified); + } CssRule::Nesting(rule) => { observe_feature( features, @@ -1236,14 +1276,11 @@ fn inventory_rules( + rule.declarations.important_declarations.len(); metrics.important_declarations += rule.declarations.important_declarations.len(); } - CssRule::Media(rule) => { - inventory_rules(&rule.rules, metrics, features, unclassified); - } - CssRule::Supports(rule) => { - inventory_rules(&rule.rules, metrics, features, unclassified); + CssRule::Viewport(rule) => { + observe_unclassified(unclassified, "@viewport", "@viewport", rule.loc); } - CssRule::MozDocument(rule) => { - inventory_rules(&rule.rules, metrics, features, unclassified); + CssRule::CustomMedia(rule) => { + observe_unclassified(unclassified, "@custom-media", "@custom-media", rule.loc); } CssRule::LayerStatement(rule) => { observe_feature( @@ -1262,6 +1299,12 @@ fn inventory_rules( ); inventory_rules(&rule.rules, metrics, features, unclassified); } + CssRule::Property(rule) => observe_feature( + features, + "registered-custom-properties", + rule.loc, + SourceMarker::Token("@property".into()), + ), CssRule::Container(rule) => { if observe_container_condition(features, rule.condition.as_ref(), rule.loc) { observe_unclassified( @@ -1291,12 +1334,15 @@ fn inventory_rules( ); inventory_rules(&rule.rules, metrics, features, unclassified); } - CssRule::Property(rule) => observe_feature( - features, - "registered-custom-properties", + CssRule::ViewTransition(rule) => observe_unclassified( + unclassified, + "@view-transition", + "@view-transition", rule.loc, - SourceMarker::Token("@property".into()), ), + // Lightning CSS uses this only for syntax it intentionally removed; + // there is no remaining authored rule to inventory. + CssRule::Ignored => {} CssRule::Unknown(rule) => { observe_unclassified( unclassified, @@ -1305,7 +1351,18 @@ fn inventory_rules( rule.loc, ); } - _ => {} + // The default parser cannot construct a custom rule. Keep this arm + // visible so a parser change cannot silently bypass the audit. + CssRule::Custom(_) => observe_unclassified( + unclassified, + "custom at-rule", + "@", + Location { + source_index: 0, + line: 0, + column: 1, + }, + ), } } } diff --git a/crates/pliego-css-build/src/artifacts/catalog.rs b/crates/pliego-css-build/src/artifacts/catalog.rs index 2c02858..1d8ea9a 100644 --- a/crates/pliego-css-build/src/artifacts/catalog.rs +++ b/crates/pliego-css-build/src/artifacts/catalog.rs @@ -10,7 +10,7 @@ use serde::Serialize; use super::optimize_css; -const CATALOG_SCHEMA_VERSION: u8 = 3; +const CATALOG_SCHEMA_VERSION: u8 = 4; /// Serialized utility-catalog representation. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -41,6 +41,7 @@ struct TokenInspection { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct BreakpointInspection { + cascade_rank: u16, name: String, min_width: String, } @@ -161,10 +162,9 @@ fn catalog_theme(theme: &ThemeRegistry) -> ThemeInspection { inspection.tokens.sort_by(|left, right| { (left.kind.as_str(), left.name.as_str()).cmp(&(right.kind.as_str(), right.name.as_str())) }); - inspection.breakpoints.sort_by(|left, right| { - (left.name.as_str(), left.min_width.as_str()) - .cmp(&(right.name.as_str(), right.min_width.as_str())) - }); + inspection + .breakpoints + .sort_by_key(|breakpoint| breakpoint.cascade_rank); inspection } @@ -184,6 +184,7 @@ fn inspect_theme(theme: &ThemeRegistry) -> ThemeInspection { .breakpoints() .iter() .map(|breakpoint| BreakpointInspection { + cascade_rank: breakpoint.cascade_rank, name: breakpoint.name.clone(), min_width: breakpoint.min_width.clone(), }) @@ -284,9 +285,12 @@ fn render_catalog_markdown(document: &CatalogDocument) -> String { output.push_str(" |\n"); } - output.push_str("\n## Breakpoints\n\n| Name | Minimum width |\n|---|---|\n"); + output + .push_str("\n## Breakpoints\n\n| Cascade rank | Name | Minimum width |\n|---:|---|---|\n"); for breakpoint in &document.theme.breakpoints { - output.push_str("| "); + output.push_str("| "); + output.push_str(&breakpoint.cascade_rank.to_string()); + output.push_str(" | "); output.push_str(&markdown_cell(&breakpoint.name)); output.push_str(" | "); output.push_str(&markdown_cell(&breakpoint.min_width)); diff --git a/crates/pliego-css-build/tests/catalog.rs b/crates/pliego-css-build/tests/catalog.rs index 21b579c..01de442 100644 --- a/crates/pliego-css-build/tests/catalog.rs +++ b/crates/pliego-css-build/tests/catalog.rs @@ -13,11 +13,11 @@ fn seed_catalog_bytes_match_the_existing_cli_contract() { assert_eq!( sha256_hex(json.as_bytes()), - "b5a2092fdfd079e2b586ac9bd850ead20599e4d9176cc358cc8bd1f10ea6273c" + "6ac31ef2bb0c1051829e2eaa6bfda389937a0e55f1489f4ad3f00d9d46e7fe93" ); assert_eq!( sha256_hex(markdown.as_bytes()), - "2efd4bf3cc25162de2996ae364876f61e4c8c98626006782bd0648ee149d335e" + "e0ebf303c80937697d09723d890487aaa2ed8db6059a766f61b3be71765ba1da" ); assert!(json.ends_with('\n')); assert!(markdown.ends_with('\n')); @@ -43,7 +43,7 @@ fn json_catalog_is_closed_over_the_frozen_top_level_shape() { "utilities", ] ); - assert_eq!(document["schemaVersion"], 3); + assert_eq!(document["schemaVersion"], 4); assert!( document["utilities"] .as_array() diff --git a/crates/pliego-css-build/tests/css_audit.rs b/crates/pliego-css-build/tests/css_audit.rs index c55e02c..b6c1908 100644 --- a/crates/pliego-css-build/tests/css_audit.rs +++ b/crates/pliego-css-build/tests/css_audit.rs @@ -56,7 +56,7 @@ fn valid_standard_css_emits_deterministic_inventory_evidence() { assert_eq!(metric("maximum-specificity"), "1,1,0"); assert_eq!( sha256_hex(json.as_bytes()), - "82d0c41ecff10f8a79263df5b064b09132444ae0b054a23f0d0d2acee0f60ff5" + "e639a6c1901c6a8e90f9e61b9847cceb1ee4a5f637cc218810dc95f4626d2f14" ); } @@ -280,6 +280,47 @@ fn unknown_container_conditions_fail_as_unclassified_syntax() { assert_eq!(finding["source"]["byteEnd"], 10); } +#[test] +fn every_supported_leaf_at_rule_is_audited_instead_of_silently_skipped() { + let cases = [ + ("@import", "@import url('theme.css');"), + ("@keyframes", "@keyframes pulse { to { opacity: 0; } }"), + ( + "@font-face", + "@font-face { font-family: Demo; src: local(Demo); }", + ), + ("@page", "@page { margin: 1cm; }"), + ( + "@counter-style", + "@counter-style thumbs { system: cyclic; symbols: 'x'; }", + ), + ( + "@namespace", + "@namespace svg url(http://www.w3.org/2000/svg);", + ), + ("@viewport", "@viewport { width: device-width; }"), + ("@custom-media", "@custom-media --narrow (width < 40rem);"), + ("@view-transition", "@view-transition { navigation: auto; }"), + ]; + for (syntax, css) in cases { + let outcome = audit_standard_css( + "src/leaf-at-rule.css", + css, + CompatibilityProfile::BaselineWidely, + ) + .unwrap_or_else(|error| panic!("{syntax}: {error}")); + let value: serde_json::Value = + serde_json::from_str(&outcome.document().to_json_pretty().expect("finding JSON")) + .expect("document"); + assert!( + value["findings"].as_array().unwrap().iter().any(|finding| { + finding["code"] == "PCSS-COMPAT-199" && finding["context"]["syntax"] == syntax + }), + "missing unclassified finding for {syntax}: {value}" + ); + } +} + #[test] fn declaration_shape_inventory_distinguishes_typed_unparsed_and_custom_values() { let css = ".card { display: grid; color: var(--ink); --gap: 1rem; mystery-prop: value; }"; diff --git a/crates/pliego-css-build/tests/internal/asset_plan.rs b/crates/pliego-css-build/tests/internal/asset_plan.rs index 5e8b38c..0a46f01 100644 --- a/crates/pliego-css-build/tests/internal/asset_plan.rs +++ b/crates/pliego-css-build/tests/internal/asset_plan.rs @@ -74,7 +74,7 @@ fn schema_one_plan_is_integrity_bound_and_canonical() { assert_eq!(plan["applicationCoverage"], "adapter-attested-complete"); assert_eq!(plan["styleIdFormatVersion"], 2); assert_eq!(plan["classNameFormatVersion"], 1); - assert_eq!(plan["themeIdFormatVersion"], 2); + assert_eq!(plan["themeIdFormatVersion"], 3); assert_eq!(plan["themeId"], "0123456789abcdef0123456789abcdef"); assert_eq!(plan["targets"], "modern"); assert_eq!(plan["format"], "minified"); @@ -562,7 +562,7 @@ fn manifest(schema: u8, css: &[u8], active_components: &[&str], emits_theme: boo "schemaVersion": schema, "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, + "themeIdFormatVersion": 3, "themeId": "0123456789abcdef0123456789abcdef", "targets": "modern", "format": "minified", diff --git a/crates/pliego-css-compiler/src/emitter.rs b/crates/pliego-css-compiler/src/emitter.rs index fbfd53c..451067b 100644 --- a/crates/pliego-css-compiler/src/emitter.rs +++ b/crates/pliego-css-compiler/src/emitter.rs @@ -226,7 +226,7 @@ fn emit_css_with_theme_inner( .into_iter() .map(|(id, assignments)| (&style.conditions[id.index()], assignments)) .collect::>(); - groups.sort_by(|left, right| compare_resolved_conditions(style, left.0, right.0)); + groups.sort_by(|left, right| compare_resolved_conditions(theme, style, left.0, right.0)); let mut css = String::new(); let mut rules = Vec::new(); @@ -431,14 +431,21 @@ fn selector( } fn compare_resolved_conditions( + theme: &ThemeRegistry, style: &SemanticStyle, left: &Condition, right: &Condition, ) -> Ordering { left.layer .cmp(&right.layer) - .then_with(|| left.breakpoint.cmp(&right.breakpoint)) - .then_with(|| left.container_breakpoint.cmp(&right.container_breakpoint)) + .then_with(|| { + breakpoint_order_key(theme, left.breakpoint) + .cmp(&breakpoint_order_key(theme, right.breakpoint)) + }) + .then_with(|| { + breakpoint_order_key(theme, left.container_breakpoint) + .cmp(&breakpoint_order_key(theme, right.container_breakpoint)) + }) .then_with(|| left.theme.cmp(&right.theme)) .then_with(|| left.motion.cmp(&right.motion)) .then_with(|| left.contrast.cmp(&right.contrast)) @@ -456,6 +463,20 @@ fn compare_resolved_conditions( }) } +fn breakpoint_order_key( + theme: &ThemeRegistry, + breakpoint: Option, +) -> (u8, u16, u16) { + match breakpoint { + None => (0, 0, 0), + Some(id) => theme + .breakpoint_by_id(id) + .map_or((2, u16::MAX, id.get()), |definition| { + (1, definition.cascade_rank, id.get()) + }), + } +} + fn wrap_condition( theme: &ThemeRegistry, mut rule: String, diff --git a/crates/pliego-css-compiler/src/engine.rs b/crates/pliego-css-compiler/src/engine.rs new file mode 100644 index 0000000..52987c4 --- /dev/null +++ b/crates/pliego-css-compiler/src/engine.rs @@ -0,0 +1,749 @@ +//! Pure, incremental analysis and physical-rule planning shared by every adapter. + +use core::fmt; +use std::collections::BTreeMap; + +use pliego_css_ir::{Diagnostic, SemanticStyle, StyleId}; +use pliego_css_parser::parse_style_list; +use pliego_css_theme::{ThemeId, ThemeRegistry}; + +use super::{ + CrossClauseConflict, CssFragmentCache, EmitError, IdentityError, StyleLineage, + analyze_cross_clause_conflicts, compose_style_override_with_theme, emit_css_with_theme_traced, + emit_theme, lower_style_with_theme, try_encode_style_identity_with_theme, +}; + +/// Maximum number of statically materialized `pcx!` branch combinations. +pub const MAX_PCX_COMBINATIONS: usize = 64; +const MAX_SEMANTIC_CACHE_ITEMS: usize = 4_096; + +/// One source-independent input accepted by the shared compiler engine. +#[non_exhaustive] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CompileInput { + /// Parse and lower one visible utility-list literal. + Literal(String), + /// Compose independently lowered branches over one base literal. + Composition { + /// Base utility-list literal. + base: String, + /// Ordered branch literals whose semantic slots override the base. + branches: Vec, + }, + /// A validated semantic style handed off by an advanced adapter. + Semantic(SemanticStyle), +} + +impl CompileInput { + /// Constructs one literal input. + #[must_use] + pub fn literal(source: impl Into) -> Self { + Self::Literal(source.into()) + } + + /// Constructs one composition input. + #[must_use] + pub fn composition( + base: impl Into, + branches: impl IntoIterator>, + ) -> Self { + Self::Composition { + base: base.into(), + branches: branches.into_iter().map(Into::into).collect(), + } + } +} + +/// Complete, I/O-free request to the shared compiler engine. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CompileRequest { + /// Inputs to lower or validate. + pub inputs: Vec, + /// Emit the active theme before physical style rules. + pub include_theme: bool, + /// Record declaration lineage for physical-rule evidence. + pub physical_trace: bool, +} + +impl CompileRequest { + /// Constructs a request from ordered inputs. + #[must_use] + pub fn new(inputs: impl IntoIterator) -> Self { + Self { + inputs: inputs.into_iter().collect(), + include_theme: false, + physical_trace: false, + } + } +} + +/// Cache activity caused by one engine request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AnalysisCacheStats { + /// Literal analyses served from the semantic cache. + pub semantic_hits: usize, + /// Literal analyses computed during this request. + pub semantic_misses: usize, + /// Physical CSS fragments served from the emission cache. + pub physical_hits: usize, + /// Physical CSS fragments emitted during this request. + pub physical_misses: usize, + /// Physical fragments removed after the complete snapshot changed. + pub physical_pruned: usize, +} + +impl AnalysisCacheStats { + fn delta(self, before: Self) -> Self { + Self { + semantic_hits: self.semantic_hits - before.semantic_hits, + semantic_misses: self.semantic_misses - before.semantic_misses, + physical_hits: self.physical_hits - before.physical_hits, + physical_misses: self.physical_misses - before.physical_misses, + physical_pruned: self.physical_pruned - before.physical_pruned, + } + } +} + +/// One collision-checked physical style in canonical emission order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompiledStyle { + /// Stable semantic IR and `StyleId`. + pub semantic: SemanticStyle, + /// Canonical identity bytes used for ordering, deduplication, and collision checks. + pub identity_stream: Vec, + /// Unoptimized deterministic CSS fragment. + pub css: String, + /// Optional physical-declaration lineage. + pub lineage: Option, +} + +/// Complete result returned by [`AnalysisHost::compile`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompileResult { + /// Theme identity used for every semantic and physical decision. + pub theme_id: ThemeId, + /// Unique styles in canonical identity-stream order. + pub styles: Vec, + /// Unoptimized deterministic theme and style CSS. + pub css: String, + /// Cache activity caused by this request. + pub cache: AnalysisCacheStats, +} + +/// Failure returned by the pure compiler engine. +#[non_exhaustive] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CompileError { + /// Parser or semantic lowering rejected one input segment. + Diagnostic { + /// Zero-based request input index. + input: usize, + /// `base`, `literal`, or `branch-N`. + segment: String, + /// Stable compiler diagnostic. + diagnostic: Diagnostic, + }, + /// An adapter supplied malformed or theme-incompatible semantic IR. + InvalidSemantic { + /// Zero-based request input index. + input: usize, + /// Structural or identity failure. + message: String, + }, + /// Two canonical streams resolved to one `StyleId`. + StyleIdCollision { + /// Colliding style identity. + style_id: StyleId, + }, + /// One canonical stream resolved to two `StyleId` values. + CanonicalStreamCollision, + /// CSS emission failed after semantic validation. + Emit(EmitError), +} + +impl fmt::Display for CompileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Diagnostic { + input, + segment, + diagnostic, + } => write!(formatter, "input {} {segment}: {diagnostic}", input + 1), + Self::InvalidSemantic { input, message } => { + write!( + formatter, + "input {} semantic IR is invalid: {message}", + input + 1 + ) + } + Self::StyleIdCollision { style_id } => write!( + formatter, + "StyleId collision under the active format for {:032x}", + style_id.get() + ), + Self::CanonicalStreamCollision => { + formatter.write_str("one canonical style stream mapped to multiple StyleId values") + } + Self::Emit(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for CompileError {} + +impl From for CompileError { + fn from(error: EmitError) -> Self { + Self::Emit(error) + } +} + +/// One physical-rule plan before target-specific CSS optimization. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhysicalRulePlan { + /// Unique styles in canonical identity-stream order. + pub styles: Vec, + /// Deterministic theme and style CSS. + pub css: String, +} + +/// Stateful planner that keeps physical emission separate from semantic identity. +#[derive(Default)] +pub struct PhysicalRulePlanner { + fragments: CssFragmentCache, +} + +impl PhysicalRulePlanner { + /// Returns the number of cached physical fragments. + #[must_use] + pub fn len(&self) -> usize { + self.fragments.len() + } + + /// Returns whether the physical fragment cache is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.fragments.is_empty() + } + + /// Plans, collision-checks, deduplicates, and emits one complete semantic snapshot. + /// + /// # Errors + /// + /// Returns structural, identity, collision, or emitter failures without performing I/O. + pub fn plan( + &mut self, + theme: &ThemeRegistry, + styles: impl IntoIterator, + include_theme: bool, + physical_trace: bool, + stats: &mut AnalysisCacheStats, + ) -> Result { + let mut unique = BTreeMap::, SemanticStyle>::new(); + let mut streams_by_id = BTreeMap::>::new(); + for (input, style) in styles.into_iter().enumerate() { + style + .validate() + .map_err(|error| CompileError::InvalidSemantic { + input, + message: error.to_string(), + })?; + let stream = try_encode_style_identity_with_theme(theme, &style).map_err( + |error: IdentityError| CompileError::InvalidSemantic { + input, + message: error.to_string(), + }, + )?; + match streams_by_id.entry(style.id) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(stream.clone()); + } + std::collections::btree_map::Entry::Occupied(entry) if entry.get() != &stream => { + return Err(CompileError::StyleIdCollision { style_id: style.id }); + } + std::collections::btree_map::Entry::Occupied(_) => {} + } + match unique.entry(stream) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(style); + } + std::collections::btree_map::Entry::Occupied(entry) + if entry.get().id != style.id => + { + return Err(CompileError::CanonicalStreamCollision); + } + std::collections::btree_map::Entry::Occupied(_) => {} + } + } + + let mut css = String::new(); + if include_theme { + css.push_str(&emit_theme(theme)); + css.push('\n'); + } + let mut planned = Vec::with_capacity(unique.len()); + for (stream, semantic) in &unique { + let (fragment, lineage) = if physical_trace { + let (fragment, lineage) = emit_css_with_theme_traced(theme, semantic)?; + stats.physical_misses += 1; + (fragment, Some(lineage)) + } else { + let (fragment, hit) = self.fragments.emit(stream, theme, semantic)?; + if hit { + stats.physical_hits += 1; + } else { + stats.physical_misses += 1; + } + (fragment.to_owned(), None) + }; + css.push_str(&fragment); + css.push('\n'); + planned.push(CompiledStyle { + semantic: semantic.clone(), + identity_stream: stream.clone(), + css: fragment, + lineage, + }); + } + stats.physical_pruned += self.fragments.retain(unique.keys()); + Ok(PhysicalRulePlan { + styles: planned, + css, + }) + } + + fn clear(&mut self) { + self.fragments = CssFragmentCache::default(); + } +} + +/// Source-independent `pcx` request shared by macros, scanners, and editors. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PcxRequest { + /// Base utility-list literal. + pub base: String, + /// Ordered clauses, each containing all statically visible branch literals. + pub clauses: Vec>, +} + +impl PcxRequest { + /// Constructs a shared conditional-analysis request. + #[must_use] + pub fn new( + base: impl Into, + clauses: impl IntoIterator>>, + ) -> Self { + Self { + base: base.into(), + clauses: clauses + .into_iter() + .map(|clause| clause.into_iter().map(Into::into).collect()) + .collect(), + } + } +} + +/// One fully composed static `pcx` branch combination. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PcxCombination { + /// Zero-based selected branch index for every clause. + pub selections: Vec, + /// Semantic result after applying the selections over the base. + pub semantic: SemanticStyle, +} + +/// Complete shared `pcx` frontend result. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PcxAnalysis { + /// Lowered base style. + pub base: SemanticStyle, + /// Lowered styles for every visible clause branch. + pub clauses: Vec>, + /// All bounded branch combinations in stable cartesian order. + pub combinations: Vec, +} + +/// Failure returned by the shared `pcx` frontend. +#[non_exhaustive] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PcxError { + /// The base literal failed parser or semantic validation. + Base(Diagnostic), + /// One visible branch failed parser or semantic validation. + Branch { + /// Zero-based clause index. + clause: usize, + /// Zero-based branch index. + branch: usize, + /// Stable compiler diagnostic. + diagnostic: Diagnostic, + }, + /// One clause did not contain a visible branch. + EmptyClause { + /// Zero-based clause index. + clause: usize, + }, + /// Independently selectable clauses overlap semantically. + Conflict(CrossClauseConflict), + /// Static materialization would exceed [`MAX_PCX_COMBINATIONS`]. + ExpansionLimit { + /// Maximum accepted combination count. + maximum: usize, + }, +} + +impl fmt::Display for PcxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Base(diagnostic) => write!(formatter, "pcx base: {diagnostic}"), + Self::Branch { + clause, + branch, + diagnostic, + } => write!( + formatter, + "pcx clause {} branch {}: {diagnostic}", + clause + 1, + branch + 1 + ), + Self::EmptyClause { clause } => { + write!( + formatter, + "pcx clause {} has no visible branches", + clause + 1 + ) + } + Self::Conflict(conflict) => write!( + formatter, + "PCX003: independent clauses {} and {} overlap", + conflict.left_clause + 1, + conflict.right_clause + 1 + ), + Self::ExpansionLimit { maximum } => write!( + formatter, + "PCX004: pcx expands to more than {maximum} style combinations" + ), + } + } +} + +impl std::error::Error for PcxError {} + +/// Long-lived semantic and physical analysis state shared by CLI, watch, and LSP adapters. +pub struct AnalysisHost { + theme: ThemeRegistry, + semantic: BTreeMap>, + planner: PhysicalRulePlanner, + stats: AnalysisCacheStats, +} + +impl Default for AnalysisHost { + fn default() -> Self { + Self::new(ThemeRegistry::seed()) + } +} + +impl AnalysisHost { + /// Starts one analysis host for an exact theme registry. + #[must_use] + pub fn new(theme: ThemeRegistry) -> Self { + Self { + theme, + semantic: BTreeMap::new(), + planner: PhysicalRulePlanner::default(), + stats: AnalysisCacheStats::default(), + } + } + + /// Returns the exact active theme. + #[must_use] + pub const fn theme(&self) -> &ThemeRegistry { + &self.theme + } + + /// Replaces the active theme and invalidates every theme-bound cache when its ID changes. + pub fn set_theme(&mut self, theme: ThemeRegistry) { + if self.theme.id() != theme.id() { + self.semantic.clear(); + self.planner.clear(); + } + self.theme = theme; + } + + /// Returns cumulative cache activity for observability and watch-mode reporting. + #[must_use] + pub const fn stats(&self) -> AnalysisCacheStats { + self.stats + } + + /// Returns the number of retained physical CSS fragments. + #[must_use] + pub fn physical_cache_len(&self) -> usize { + self.planner.len() + } + + /// Parses and lowers one literal through the shared semantic cache. + /// + /// # Errors + /// + /// Returns the stable parser or semantic diagnostic. + pub fn analyze_literal(&mut self, source: &str) -> Result { + if let Some(cached) = self.semantic.get(source) { + self.stats.semantic_hits += 1; + return cached.clone(); + } + if self.semantic.len() >= MAX_SEMANTIC_CACHE_ITEMS { + self.semantic.clear(); + } + self.stats.semantic_misses += 1; + let result = parse_style_list(source) + .and_then(|syntax| lower_style_with_theme(&self.theme, &syntax)); + self.semantic.insert(source.to_owned(), result.clone()); + result + } + + /// Runs the shared bounded conditional frontend. + /// + /// # Errors + /// + /// Returns literal diagnostics, empty clauses, cross-clause conflicts, or expansion overflow. + pub fn analyze_pcx(&mut self, request: &PcxRequest) -> Result { + let base = self + .analyze_literal(&request.base) + .map_err(PcxError::Base)?; + let mut clauses = Vec::with_capacity(request.clauses.len()); + for (clause_index, clause) in request.clauses.iter().enumerate() { + if clause.is_empty() { + return Err(PcxError::EmptyClause { + clause: clause_index, + }); + } + let mut branches = Vec::with_capacity(clause.len()); + for (branch_index, source) in clause.iter().enumerate() { + branches.push(self.analyze_literal(source).map_err(|diagnostic| { + PcxError::Branch { + clause: clause_index, + branch: branch_index, + diagnostic, + } + })?); + } + clauses.push(branches); + } + analyze_cross_clause_conflicts(&clauses).map_err(PcxError::Conflict)?; + let branch_counts = clauses.iter().map(Vec::len).collect::>(); + let valid_count = branch_counts.iter().try_fold(1_usize, |count, branches| { + count + .checked_mul(*branches) + .filter(|next| *next <= MAX_PCX_COMBINATIONS) + }); + if valid_count.is_none() { + return Err(PcxError::ExpansionLimit { + maximum: MAX_PCX_COMBINATIONS, + }); + } + let mut combinations = Vec::new(); + for selections in cartesian_indices(&branch_counts) { + let mut semantic = base.clone(); + for (clause, branch) in clauses.iter().zip(&selections) { + semantic = compose_style_override_with_theme( + &self.theme, + semantic, + clause[*branch].clone(), + ); + } + combinations.push(PcxCombination { + selections, + semantic, + }); + } + Ok(PcxAnalysis { + base, + clauses, + combinations, + }) + } + + /// Executes one complete pure compiler request. + /// + /// # Errors + /// + /// Returns stable input diagnostics or physical planning failures. + pub fn compile(&mut self, request: &CompileRequest) -> Result { + let before = self.stats; + let mut semantics = Vec::with_capacity(request.inputs.len()); + for (input_index, input) in request.inputs.iter().enumerate() { + let semantic = match input { + CompileInput::Literal(source) => { + self.analyze_literal(source) + .map_err(|diagnostic| CompileError::Diagnostic { + input: input_index, + segment: "literal".into(), + diagnostic, + })? + } + CompileInput::Composition { base, branches } => { + let mut semantic = self.analyze_literal(base).map_err(|diagnostic| { + CompileError::Diagnostic { + input: input_index, + segment: "base".into(), + diagnostic, + } + })?; + for (branch_index, branch) in branches.iter().enumerate() { + let branch = self.analyze_literal(branch).map_err(|diagnostic| { + CompileError::Diagnostic { + input: input_index, + segment: format!("branch-{}", branch_index + 1), + diagnostic, + } + })?; + semantic = compose_style_override_with_theme(&self.theme, semantic, branch); + } + semantic + } + CompileInput::Semantic(semantic) => semantic.clone(), + }; + semantics.push(semantic); + } + let plan = self.planner.plan( + &self.theme, + semantics, + request.include_theme, + request.physical_trace, + &mut self.stats, + )?; + Ok(CompileResult { + theme_id: self.theme.id(), + styles: plan.styles, + css: plan.css, + cache: self.stats.delta(before), + }) + } +} + +fn cartesian_indices(branch_counts: &[usize]) -> Vec> { + let mut combinations = vec![Vec::new()]; + for branch_count in branch_counts { + let mut next = Vec::with_capacity(combinations.len() * branch_count); + for prefix in combinations { + for branch in 0..*branch_count { + let mut combination = prefix.clone(); + combination.push(branch); + next.push(combination); + } + } + combinations = next; + } + combinations +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compile_request_is_deterministic_and_incremental() { + let mut host = AnalysisHost::default(); + let request = CompileRequest { + inputs: vec![ + CompileInput::literal("flex p-4"), + CompileInput::literal("grid gap-2"), + CompileInput::literal("flex p-4"), + ], + include_theme: false, + physical_trace: false, + }; + let first = host.compile(&request).unwrap(); + let second = host.compile(&request).unwrap(); + assert_eq!(first.css, second.css); + assert_eq!(first.styles.len(), 2); + assert_eq!(first.cache.semantic_misses, 2); + assert_eq!(first.cache.semantic_hits, 1); + assert_eq!(second.cache.semantic_hits, 3); + assert_eq!(second.cache.semantic_misses, 0); + assert_eq!(second.cache.physical_hits, 2); + } + + #[test] + fn pcx_frontend_materializes_shared_combinations() { + let mut host = AnalysisHost::default(); + let analysis = host + .analyze_pcx(&PcxRequest::new( + "flex", + [["p-2", "p-4"], ["text-ink", "text-muted"]], + )) + .unwrap(); + assert_eq!(analysis.combinations.len(), 4); + assert_eq!(analysis.combinations[0].selections, [0, 0]); + assert_eq!(analysis.combinations[3].selections, [1, 1]); + } + + #[test] + fn pcx_frontend_rejects_cross_clause_overlap() { + let mut host = AnalysisHost::default(); + let error = host + .analyze_pcx(&PcxRequest::new("flex", [["p-2", "p-4"], ["px-1", "px-3"]])) + .unwrap_err(); + assert!(matches!(error, PcxError::Conflict(_))); + } + + #[test] + fn pcx_frontend_enforces_the_shared_expansion_limit() { + let mut host = AnalysisHost::default(); + let error = host + .analyze_pcx(&PcxRequest::new( + "p-1", + [ + ["[g2-a:0]", "[g2-a:1]"], + ["[g2-b:0]", "[g2-b:1]"], + ["[g2-c:0]", "[g2-c:1]"], + ["[g2-d:0]", "[g2-d:1]"], + ["[g2-e:0]", "[g2-e:1]"], + ["[g2-f:0]", "[g2-f:1]"], + ["[g2-g:0]", "[g2-g:1]"], + ], + )) + .unwrap_err(); + assert_eq!( + error, + PcxError::ExpansionLimit { + maximum: MAX_PCX_COMBINATIONS + } + ); + } + + #[test] + fn physical_planner_prunes_styles_absent_from_the_complete_snapshot() { + let mut host = AnalysisHost::default(); + host.compile(&CompileRequest::new([ + CompileInput::literal("flex"), + CompileInput::literal("grid"), + ])) + .unwrap(); + assert_eq!(host.physical_cache_len(), 2); + + let result = host + .compile(&CompileRequest::new([CompileInput::literal("grid")])) + .unwrap(); + assert_eq!(result.cache.physical_hits, 1); + assert_eq!(result.cache.physical_pruned, 1); + assert_eq!(host.physical_cache_len(), 1); + } + + #[test] + fn theme_change_invalidates_semantic_cache() { + let mut host = AnalysisHost::default(); + host.analyze_literal("flex").unwrap(); + host.analyze_literal("flex").unwrap(); + let before = host.stats(); + let seed = ThemeRegistry::seed(); + let mut tokens = seed.tokens().to_vec(); + tokens[0].value = "1px".into(); + let theme = ThemeRegistry::from_definitions(tokens, seed.breakpoints().to_vec()).unwrap(); + assert_ne!(theme.id(), seed.id()); + host.set_theme(theme); + host.analyze_literal("flex").unwrap(); + assert_eq!(host.stats().semantic_hits, before.semantic_hits); + assert_eq!(host.stats().semantic_misses, before.semantic_misses + 1); + } +} diff --git a/crates/pliego-css-compiler/src/identity.rs b/crates/pliego-css-compiler/src/identity.rs index 2cf0c0c..d1642f7 100644 --- a/crates/pliego-css-compiler/src/identity.rs +++ b/crates/pliego-css-compiler/src/identity.rs @@ -1278,17 +1278,17 @@ mod tests { assert_eq!( hex(&stream), - "706c6965676f2d7374796c652d6964000002010002b3d5ad77175995c2b8f51ef7c0d419910200000003030000001f110e120112130900000007312e323572656d140100150001010100000000000300000024112012012b13070313e3e9b7011388140001150003030201010000000100000003263e700300000030113c00000007636f6e74656e7412015313090000000d27636166c3a97c3af09f98802714000015000101010000000000" + "706c6965676f2d7374796c652d6964000002010003b98b78da29201938d135b8bb947177880200000003030000001f110e120112130900000007312e323572656d140100150001010100000000000300000024112012012b13070313e3e9b7011388140001150003030201010000000100000003263e700300000030113c00000007636f6e74656e7412015313090000000d27636166c3a97c3af09f98802714000015000101010000000000" ); assert_eq!( format!("{:x}", Sha256::digest(&stream)), - "f22fe79baa3e45505636d5a9e245d3d0934f8a45afbe662db9f841683277cc04" + "a3ce828a00a553726c6b9322bd6b2262bcdf66215326f8754329c6452bfcf266" ); assert_eq!( format!("{:032x}", style.id.get()), - "f22fe79baa3e45505636d5a9e245d3d0" + "a3ce828a00a553726c6b9322bd6b2262" ); - assert_eq!(style.id.to_class_name(), "pc_ec64pw6t451sjonk2yafopa6o"); + assert_eq!(style.id.to_class_name(), "pc_9p4ab788swo2xs7828cmvjdk2"); } #[test] diff --git a/crates/pliego-css-compiler/src/ir_binary.rs b/crates/pliego-css-compiler/src/ir_binary.rs index a1bc69f..7fedde2 100644 --- a/crates/pliego-css-compiler/src/ir_binary.rs +++ b/crates/pliego-css-compiler/src/ir_binary.rs @@ -1869,12 +1869,12 @@ mod tests { assert_eq!(bytes.len(), 309); assert_eq!( hex(&digest), - "337865f8e7b5b32fe58f67442537ad1d2926d94164d1692863a2616dddc85023" + "1d19d4bf7735acaf6b574d95fdb930389713c55983fff87c4c26a6cc2880532b" ); assert_eq!( hex(&bytes), concat!( - "504c474349520000000200020002b3d5ad77175995c2b8f51ef7c0d419919dda0d2bd9a9bc5c77077006110d08717f17", + "504c474349520000000200020003b98b78da29201938d135b8bb94717788647ff20792ad80e5851585f065bdc97e7f17", "43fbde6e0fc37de303b085e83ff58ce3791ebfa0a06c443a936792d0350d000000050000001511011201011301041400", "001500010101000000000000000000000000050000001c110e1201121309000000043272656d14010115000101010000", "000000000000290000003400000028112012012b13070313e3e9b70107d0140000150100010301010101000000010000", diff --git a/crates/pliego-css-compiler/src/lib.rs b/crates/pliego-css-compiler/src/lib.rs index 2304ec8..a45752f 100644 --- a/crates/pliego-css-compiler/src/lib.rs +++ b/crates/pliego-css-compiler/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] mod emitter; +mod engine; mod identity; mod ir_binary; @@ -14,6 +15,11 @@ pub use emitter::{ emit_css, emit_css_with_theme, emit_css_with_theme_traced, emit_seed_theme, emit_theme, emit_theme_references, emit_used_theme, referenced_tokens, theme_custom_property_name, }; +pub use engine::{ + AnalysisCacheStats, AnalysisHost, CompileError, CompileInput, CompileRequest, CompileResult, + CompiledStyle, MAX_PCX_COMBINATIONS, PcxAnalysis, PcxCombination, PcxError, PcxRequest, + PhysicalRulePlan, PhysicalRulePlanner, +}; pub use identity::{ IdentityError, STYLE_ID_FORMAT_VERSION, derive_style_id, derive_style_id_with_theme, try_derive_style_id, try_derive_style_id_with_theme, try_encode_style_identity, @@ -39,7 +45,8 @@ use pliego_css_ir::{ classified_attribute_name, classified_selector_for_variant, is_typed_attribute_variant, }; use pliego_css_parser::{ - NamedCandidateSyntax, OperandKind, OperandSyntax, parse_named_candidate_at, + NamedCandidateSyntax, OperandKind, OperandSyntax, parse_named_candidate, + parse_named_candidate_at, }; use pliego_css_theme::ThemeRegistry; @@ -836,6 +843,37 @@ pub const fn utility_catalog() -> &'static [UtilityDescriptor] { CATALOG } +/// Resolves the compiler-owned catalog descriptor for one parsed style item. +/// +/// This read-only bridge lets editor and inspection adapters explain the exact utility accepted by +/// lowering without reimplementing longest-prefix catalog resolution. +#[must_use] +pub fn utility_descriptor_for_style_item(item: &StyleItem) -> Option<&'static UtilityDescriptor> { + match &item.candidate.kind { + CandidateKind::ArbitraryProperty { .. } => utility_catalog() + .iter() + .find(|descriptor| descriptor.form() == UtilityForm::ArbitraryProperty), + CandidateKind::Named(candidate) => { + let body = parse_named_candidate(candidate).ok()?.body; + utility_catalog() + .iter() + .find(|descriptor| { + descriptor.form() == UtilityForm::Fixed && descriptor.match_name() == body + }) + .or_else(|| { + utility_catalog() + .iter() + .filter(|descriptor| descriptor.form() == UtilityForm::Parameterized) + .filter(|descriptor| { + body.strip_prefix(descriptor.match_name()) + .is_some_and(|suffix| suffix.starts_with('-')) + }) + .max_by_key(|descriptor| descriptor.match_name().len()) + }) + } + } +} + fn seed_theme() -> &'static ThemeRegistry { static SEED: OnceLock = OnceLock::new(); SEED.get_or_init(ThemeRegistry::seed) @@ -2444,6 +2482,7 @@ mod tests { ], [BreakpointDefinition::new( pliego_css_ir::BreakpointId::new(7), + 0, "tablet", "52rem", )], @@ -2461,13 +2500,13 @@ mod tests { assert_eq!(pliego_css_ir::CLASS_NAME_FORMAT_VERSION, 1); assert_eq!( format!("{:032x}", theme.id().get()), - "cf5c4c0674fd1c7e5121da0d27222ae0" + "eda25b5ed8fa8ce973662d4f18a46bdc" ); assert_eq!( format!("{:032x}", style.id.get()), - "e0b572e3fdfbf091d9a2ddb126278634" + "b742ceb589d4f412c6ba77e81f632f53" ); - assert_eq!(style.id.to_class_name(), "pc_dax2y1pql4op1rjk97yv9e88k"); + assert_eq!(style.id.to_class_name(), "pc_aukxxmkm8bmauj8duf8zpjdcj"); } #[test] @@ -2478,13 +2517,13 @@ mod tests { assert_eq!( format!("{:032x}", theme.id().get()), - "b3d5ad77175995c2b8f51ef7c0d41991" + "b98b78da29201938d135b8bb94717788" ); assert_eq!( format!("{:032x}", style.id.get()), - "70cb04ef9bf9621f5826351f1778f68e" + "fe3a92576be2bb53e3240249bc45b829" ); - assert_eq!(style.id.to_class_name(), "pc_6oe73ec16rbb7ublcoa3bpzf2"); + assert_eq!(style.id.to_class_name(), "pc_f1u1l7d58kemkdqdjie56hdex"); } #[test] diff --git a/crates/pliego-css-compiler/tests/container_queries.rs b/crates/pliego-css-compiler/tests/container_queries.rs index 2f24d14..65a9ced 100644 --- a/crates/pliego-css-compiler/tests/container_queries.rs +++ b/crates/pliego-css-compiler/tests/container_queries.rs @@ -20,9 +20,9 @@ fn establishes_and_queries_an_inline_size_container() { assert_eq!( format!("{:032x}", style.id.get()), - "11893074aaaf88f7b356ad6d53a913e2" + "78f8ca7e9a1d8b7737ee243d87faf881" ); - assert_eq!(class, "pc_11dgqogrs5aq1d9ocwyulg6w2"); + assert_eq!(class, "pc_75tq511l3l9q0mx595ykqafsx"); assert_eq!( emit_css(&style).expect("container fixture must emit"), @@ -66,8 +66,8 @@ fn active_theme_owns_viewport_and_container_widths() { let theme = ThemeRegistry::from_definitions( seed.tokens().iter().cloned(), [ - BreakpointDefinition::new(BreakpointId::new(0), "sm", "32rem"), - BreakpointDefinition::new(BreakpointId::new(1), "md", "44rem"), + BreakpointDefinition::new(BreakpointId::new(0), 0, "sm", "32rem"), + BreakpointDefinition::new(BreakpointId::new(1), 1, "md", "44rem"), ], ) .expect("custom theme must be valid"); diff --git a/crates/pliego-css-compiler/tests/property_contract.rs b/crates/pliego-css-compiler/tests/property_contract.rs index 2c39ebf..0697521 100644 --- a/crates/pliego-css-compiler/tests/property_contract.rs +++ b/crates/pliego-css-compiler/tests/property_contract.rs @@ -8,9 +8,10 @@ use pliego_css_compiler::{ emit_css_with_theme, encode_style_ir_with_theme, lower_style_with_theme, try_encode_style_identity_with_theme, utility_catalog, }; +use pliego_css_ir::BreakpointId; use pliego_css_ir::SemanticStyle; use pliego_css_parser::{format_style_list, parse_style_list}; -use pliego_css_theme::ThemeRegistry; +use pliego_css_theme::{BreakpointDefinition, ThemeRegistry}; const PROPERTY_CASES: usize = 2_048; const CONCURRENT_CASES: usize = 192; @@ -19,6 +20,7 @@ const THREAD_COUNT: usize = 16; const CORPUS_SEED: u64 = 0x504c_4945_474f_4353; const PERMUTATION_SEED: u64 = 0x5354_594c_4549_4431; const CONCURRENCY_SEED: u64 = 0x434f_4e43_5552_5245; +const BREAKPOINT_ORDER_SEED: u64 = 0x4252_4541_4b50_4f49; const VARIANT_PREFIXES: &[&str] = &[ "", @@ -247,6 +249,63 @@ fn non_conflicting_permutations_have_one_identity_and_css() { } } +#[test] +fn breakpoint_cascade_order_is_independent_of_ids_names_and_input_permutations() { + let seed = ThemeRegistry::seed(); + let mut rng = FixedRng::new(BREAKPOINT_ORDER_SEED); + let narrow_names = ["tablet", "zeta-narrow", "screen-a"]; + let middle_names = ["content-wide", "alpha-middle", "screen-b"]; + let wide_names = ["desktop", "beta-wide", "screen-c"]; + + for case_index in 0..PROPERTY_CASES { + let names = [ + narrow_names[rng.index(narrow_names.len())], + middle_names[rng.index(middle_names.len())], + wide_names[rng.index(wide_names.len())], + ]; + let mut ids = [7_u16, 40_000, 60_000]; + rng.shuffle(&mut ids); + let mut breakpoints = vec![ + BreakpointDefinition::new(BreakpointId::new(ids[0]), 0, names[0], "52rem"), + BreakpointDefinition::new(BreakpointId::new(ids[1]), 1, names[1], "72rem"), + BreakpointDefinition::new(BreakpointId::new(ids[2]), 2, names[2], "80rem"), + ]; + rng.shuffle(&mut breakpoints); + let theme = ThemeRegistry::from_definitions(seed.tokens().iter().cloned(), breakpoints) + .unwrap_or_else(|error| panic!("case {case_index} theme must build: {error}")); + + let media_source = format!( + "{}:grid-cols-2 {}:grid-cols-3 {}:grid-cols-4", + names[0], names[1], names[2] + ); + let media_css = full_contract(&theme, &media_source).semantic.css; + let media_positions = ["52rem", "72rem", "80rem"].map(|width| { + media_css.find(width).unwrap_or_else(|| { + panic!("case {case_index} media CSS misses {width}: {media_css}") + }) + }); + assert!( + media_positions.windows(2).all(|pair| pair[0] < pair[1]), + "case {case_index} media cascade is not narrow-to-wide: {media_css}" + ); + + let container_source = format!( + "cq-{}:grid-cols-2 cq-{}:grid-cols-3 cq-{}:grid-cols-4", + names[0], names[1], names[2] + ); + let container_css = full_contract(&theme, &container_source).semantic.css; + let container_positions = ["52rem", "72rem", "80rem"].map(|width| { + container_css.find(width).unwrap_or_else(|| { + panic!("case {case_index} container CSS misses {width}: {container_css}") + }) + }); + assert!( + container_positions.windows(2).all(|pair| pair[0] < pair[1]), + "case {case_index} container cascade is not narrow-to-wide: {container_css}" + ); + } +} + #[test] fn shared_theme_is_deterministic_across_sixteen_threads() { let theme = Arc::new(ThemeRegistry::seed()); diff --git a/crates/pliego-css-config/src/dtcg.rs b/crates/pliego-css-config/src/dtcg.rs index 88dbb74..da2b078 100644 --- a/crates/pliego-css-config/src/dtcg.rs +++ b/crates/pliego-css-config/src/dtcg.rs @@ -490,6 +490,8 @@ fn build_theme(document: Value) -> Result { &mut input_names, &mut report, )?; + super::rank_breakpoints(&mut breakpoints) + .map_err(|error| invalid(&error.name, error.reason))?; let registry = ThemeRegistry::from_definitions(tokens, breakpoints).map_err(DtcgError::Registry)?; verify_profile_theme_id(root, profile, ®istry)?; @@ -1459,7 +1461,7 @@ fn merge_breakpoint( .iter_mut() .find(|definition| definition.name == name) { - *existing = BreakpointDefinition::new(existing.id, name, value); + *existing = BreakpointDefinition::new(existing.id, existing.cascade_rank, name, value); return Ok(()); } let next = breakpoints @@ -1471,6 +1473,7 @@ fn merge_breakpoint( .ok_or_else(|| invalid("breakpoints", "breakpoint ID space is exhausted"))?; breakpoints.push(BreakpointDefinition::new( BreakpointId::new(next), + 0, name, value, )); diff --git a/crates/pliego-css-config/src/lib.rs b/crates/pliego-css-config/src/lib.rs index 8200774..256ff62 100644 --- a/crates/pliego-css-config/src/lib.rs +++ b/crates/pliego-css-config/src/lib.rs @@ -341,10 +341,12 @@ fn apply_breakpoints( .iter_mut() .find(|definition| definition.name == name) { - *existing = BreakpointDefinition::new(existing.id, name, min_width); + *existing = + BreakpointDefinition::new(existing.id, existing.cascade_rank, name, min_width); } else { definitions.push(BreakpointDefinition::new( BreakpointId::new(next_id), + 0, name, min_width, )); @@ -355,9 +357,69 @@ fn apply_breakpoints( })?; } } + rank_breakpoints(definitions).map_err(|error| ConfigError::InvalidValue { + table, + name: error.name, + reason: error.reason, + }) +} + +#[derive(Debug)] +struct BreakpointOrderError { + name: String, + reason: &'static str, +} + +fn rank_breakpoints(definitions: &mut [BreakpointDefinition]) -> Result<(), BreakpointOrderError> { + let mut ranked = Vec::with_capacity(definitions.len()); + let mut common_unit = None; + for (index, definition) in definitions.iter().enumerate() { + let Some((number, unit)) = breakpoint_number_and_unit(&definition.min_width) else { + return Err(BreakpointOrderError { + name: definition.name.clone(), + reason: "breakpoint must be a positive CSS length in px, rem, em, ch, or vw", + }); + }; + if let Some(expected) = common_unit { + if expected != unit { + return Err(BreakpointOrderError { + name: definition.name.clone(), + reason: "all breakpoints must use one common unit so cascade order is unambiguous", + }); + } + } else { + common_unit = Some(unit); + } + ranked.push((index, number, definition.name.clone(), definition.id)); + } + ranked.sort_by(|left, right| { + left.1 + .total_cmp(&right.1) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.3.cmp(&right.3)) + }); + for (rank, (index, _, _, _)) in ranked.into_iter().enumerate() { + definitions[index].cascade_rank = + u16::try_from(rank).map_err(|_| BreakpointOrderError { + name: "".to_owned(), + reason: "breakpoint cascade rank space is exhausted", + })?; + } Ok(()) } +fn breakpoint_number_and_unit(value: &str) -> Option<(f64, &str)> { + let split = value + .find(|character: char| !character.is_ascii_digit() && character != '.') + .unwrap_or(value.len()); + let (number, unit) = value.split_at(split); + let number = number + .parse::() + .ok() + .filter(|number| number.is_finite() && *number > 0.0)?; + matches!(unit, "px" | "rem" | "em" | "ch" | "vw").then_some((number, unit)) +} + fn normalize_name(table: &'static str, name: &str) -> Result { let name = name.trim(); if name.is_empty() { @@ -528,6 +590,50 @@ Content_Wide = "72rem" assert!(registry.breakpoint_by_name("content-wide").is_some()); } + #[test] + fn breakpoint_ids_do_not_determine_narrow_to_wide_cascade_ranks() { + let registry = parse_str( + r#" +schema = 1 +extends = "seed" + +[breakpoints] +content-wide = "72rem" +tablet = "52rem" +"#, + ) + .expect("ordered breakpoints"); + let tablet = registry.breakpoint_by_name("tablet").expect("tablet"); + let content_wide = registry + .breakpoint_by_name("content-wide") + .expect("content-wide"); + + assert!( + content_wide.id < tablet.id, + "IDs remain stable by canonical name" + ); + assert!( + tablet.cascade_rank < content_wide.cascade_rank, + "cascade rank follows 52rem before 72rem" + ); + } + + #[test] + fn mixed_breakpoint_units_fail_closed_instead_of_guessing_cascade_order() { + let error = parse_str( + r#" +schema = 1 +extends = "seed" + +[breakpoints] +tablet = "900px" +"#, + ) + .expect_err("mixed units must be ambiguous"); + assert!(matches!(error, ConfigError::InvalidValue { .. })); + assert!(error.to_string().contains("one common unit")); + } + #[test] fn seed_tokens_can_be_overridden() { let registry = parse_str( diff --git a/crates/pliego-css-control/README.md b/crates/pliego-css-control/README.md index 95d1447..5a8cc06 100644 --- a/crates/pliego-css-control/README.md +++ b/crates/pliego-css-control/README.md @@ -9,4 +9,4 @@ SARIF, and bounded accessibility analysis. Schema-only consumers leave it disabl ## Stability -Published as exact-version public-preview tooling in `0.1.0-rc.2`; final `0.1.0` is not published. +Prepared as exact-version public-preview tooling for `0.1.0-rc.3`; final `0.1.0` is not published. diff --git a/crates/pliego-css-lsp/Cargo.toml b/crates/pliego-css-lsp/Cargo.toml index da4a0a8..65dd9a3 100644 --- a/crates/pliego-css-lsp/Cargo.toml +++ b/crates/pliego-css-lsp/Cargo.toml @@ -19,9 +19,13 @@ name = "pliego-css-lsp" path = "src/main.rs" [dependencies] +pliego-css-compiler.workspace = true +pliego-css-config.workspace = true +pliego-css-ir.workspace = true pliego-css-io.workspace = true pliego-css-parser.workspace = true pliego-css-source.workspace = true +pliego-css-theme.workspace = true serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10" diff --git a/crates/pliego-css-lsp/README.md b/crates/pliego-css-lsp/README.md index 4561332..10b7d84 100644 --- a/crates/pliego-css-lsp/README.md +++ b/crates/pliego-css-lsp/README.md @@ -1,12 +1,9 @@ # pliego-css-lsp `pliego-css-lsp` is the public-preview standard-LSP transport for PliegoCSS Rust literals. It keeps -open documents in memory, negotiates UTF-16 positions, publishes scanner and `FMT001` diagnostics, -returns collision-safe whole-literal formatting edits, and delegates completion/hover metadata to -the versioned `pliego-cssc` catalog/explain JSON contracts. With an explicit generated Project -Index, Go to Definition follows verified source sites and manifests to final CSS declarations. -Syntactically valid literals also reuse the compiler's diagnostic schema for bounded theme-aware -semantic checks instead of maintaining an editor-only utility validator. +open documents in memory, negotiates UTF-16 positions, publishes scanner, formatter, semantic, and +`pcx` diagnostics, and serves completion and hover from the same in-process compiler engine used by +the CLI and watch mode. It does not launch `pliego-cssc` or create temporary source files. Print the installed package version without entering stdio mode: @@ -17,7 +14,7 @@ pliego-css-lsp --version Run it over stdio from the project root: ```console -pliego-css-lsp --pliego-cssc /exact/path/to/pliego-cssc --seed +pliego-css-lsp --seed ``` Enable source-to-CSS navigation without repository inference: @@ -26,6 +23,9 @@ Enable source-to-CSS navigation without repository inference: pliego-css-lsp --project-index target/site/assets/pliego.index.json --seed ``` -Without `--seed`, the delegated compiler uses normal conventional theme discovery. `--config` -selects one exact theme file. The server currently requests full document synchronization and only -offers semantic features inside statically supported `pc!`/`pcx!` Rust string literals. +Without `--seed`, the server discovers `pliego.theme.toml` at the initialized workspace root. +`--config` selects one exact theme file. `--pliego-cssc` and `PLIEGO_CSSC` remain accepted during +the 0.1 compatibility window, but are ignored: semantic analysis is always in-process. + +The server requests full document synchronization and offers semantic features only inside +statically supported `pc!`/`pcx!` Rust string literals. diff --git a/crates/pliego-css-lsp/src/lib.rs b/crates/pliego-css-lsp/src/lib.rs index cda16cc..6750cc6 100644 --- a/crates/pliego-css-lsp/src/lib.rs +++ b/crates/pliego-css-lsp/src/lib.rs @@ -4,19 +4,18 @@ use std::collections::BTreeMap; use std::ffi::OsString; -use std::fmt::Write as _; -use std::fs::{OpenOptions, remove_file}; -use std::io::{self, BufRead, BufReader, Read, Write}; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; +use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use pliego_css_compiler::{ + AnalysisHost, PcxError, PcxRequest, emit_css_with_theme, utility_catalog, + utility_descriptor_for_style_item, +}; +use pliego_css_ir::Diagnostic; use pliego_css_parser::{format_style_list, parse_style_list}; use pliego_css_source::{InvocationKind, ScanReport, SourceRange, StyleLiteral, scan_source_named}; use serde_json::{Value, json}; @@ -25,20 +24,15 @@ use sha2::{Digest, Sha256}; mod project_index; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const MAX_SEMANTIC_CACHE_ITEMS: usize = 4_096; const MAX_SEMANTIC_CHECKS_PER_DOCUMENT: usize = 256; const MAX_CONFIG_FINGERPRINT_BYTES: u64 = 1024 * 1024; const MAX_HEADER_BYTES: usize = 64 * 1024; -const MAX_TOOL_OUTPUT_BYTES: usize = 4 * 1024 * 1024; const SEMANTIC_DEBOUNCE: Duration = Duration::from_millis(150); -const SEMANTIC_PROCESS_POLL: Duration = Duration::from_millis(10); -const TOOL_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); -static TEMP_SOURCE_COUNTER: AtomicU64 = AtomicU64::new(0); -/// Exact process and theme selection used by delegated tooling requests. +/// Exact theme and navigation selection used by in-process analysis. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ServerConfig { - /// Exact `pliego-cssc` executable name or path. + /// Legacy 0.1 compatibility option. The in-process engine never executes this path. pub compiler: OsString, /// Use the deterministic seed theme instead of conventional discovery. pub seed: bool, @@ -119,7 +113,8 @@ struct Server { config: ServerConfig, root: PathBuf, documents: BTreeMap, - catalog: Option<(String, Vec)>, + catalog: Option>, + analysis: Option<(String, AnalysisHost)>, shutdown: bool, } @@ -130,165 +125,94 @@ impl Server { root: PathBuf::from("."), documents: BTreeMap::new(), catalog: None, + analysis: None, shutdown: false, } } - fn tool(&self, command: &str, extra: &[&str]) -> Result { - let mut process = Command::new(&self.config.compiler); - process.arg(command).args(extra).current_dir(&self.root); - if self.config.seed { - process.arg("--seed"); - } else if let Some(config) = &self.config.config { - process.arg("--config").arg(config); - } - process.arg("--format").arg("json"); - let output = bounded_process_output( - &mut process, - "pliego-cssc delegated tool", - TOOL_PROCESS_TIMEOUT, - )?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()); + fn catalog(&mut self) -> &[Value] { + if self.catalog.is_none() { + self.catalog = Some( + utility_catalog() + .iter() + .map(|descriptor| { + json!({ + "pattern":descriptor.pattern(), + "matchName":descriptor.match_name(), + "example":descriptor.example(), + "summary":descriptor.summary(), + }) + }) + .collect(), + ); } - serde_json::from_slice(&output.stdout) - .map_err(|error| format!("invalid pliego-cssc JSON: {error}")) + self.catalog.as_deref().unwrap_or_default() } - fn catalog(&mut self) -> Result<&[Value], String> { + fn analysis(&mut self) -> Result<&mut AnalysisHost, String> { let identity = cache_identity(&self.root, &self.config)?; - if self.catalog.as_ref().map(|(key, _)| key) != Some(&identity) { - let document = self.tool("catalog", &[])?; - if document.get("schemaVersion") != Some(&json!(3)) { - return Err("unsupported catalog schema".into()); - } - self.catalog = Some(( + if self.analysis.as_ref().map(|(key, _)| key) != Some(&identity) { + self.analysis = Some(( identity, - document - .get("utilities") - .and_then(Value::as_array) - .ok_or("catalog has no utilities")? - .clone(), + AnalysisHost::new(load_theme(&self.root, &self.config)?), )); } - Ok(self.catalog.as_ref().map_or(&[], |(_, values)| values)) + Ok(&mut self.analysis.as_mut().expect("analysis inserted").1) } } struct SemanticEngine { config: ServerConfig, - cache: BTreeMap, String>>, + identity: Option, + host: AnalysisHost, } impl SemanticEngine { fn new(config: ServerConfig) -> Self { Self { config, - cache: BTreeMap::new(), + identity: None, + host: AnalysisHost::default(), } } - fn findings( - &mut self, - root: &Path, - style: &str, - cancellation: &Cancellation, - job: &SemanticJob, - ) -> Result, String> { - let key = format!("{}\0style:{style}", cache_identity(root, &self.config)?); - if !self.cache.contains_key(&key) { - if self.cache.len() >= MAX_SEMANTIC_CACHE_ITEMS { - self.cache.clear(); - } - let Some(result) = self.run_check(root, style, cancellation, job)? else { - return Ok(None); - }; - self.cache.insert(key.clone(), result); + fn ensure_root(&mut self, root: &Path) -> Result<(), String> { + let identity = cache_identity(root, &self.config)?; + if self.identity.as_ref() != Some(&identity) { + self.host.set_theme(load_theme(root, &self.config)?); + self.identity = Some(identity); } - self.cache - .get(&key) - .expect("semantic result inserted") - .as_deref() - .map_err(Clone::clone) - .map(Some) + Ok(()) } - fn pcx_findings( + fn analyze_literal( &mut self, root: &Path, - source: &str, - cancellation: &Cancellation, - job: &SemanticJob, - ) -> Result, String> { - let key = format!("{}\0pcx:{source}", cache_identity(root, &self.config)?); - if !self.cache.contains_key(&key) { - if self.cache.len() >= MAX_SEMANTIC_CACHE_ITEMS { - self.cache.clear(); - } - let Some(result) = self.run_source_check(root, source, cancellation, job)? else { - return Ok(None); - }; - self.cache.insert(key.clone(), result); - } - self.cache - .get(&key) - .expect("pcx semantic result inserted") - .as_deref() - .map_err(Clone::clone) - .map(Some) - } - - fn run_check( - &self, - root: &Path, style: &str, cancellation: &Cancellation, job: &SemanticJob, - ) -> Result, String>>, String> { - let mut process = Command::new(&self.config.compiler); - process - .arg("--diagnostic-format") - .arg("json") - .arg("check") - .arg("--style") - .arg(style) - .current_dir(root); - if self.config.seed { - process.arg("--seed"); - } else if let Some(config) = &self.config.config { - process.arg("--config").arg(config); - } - let Some(output) = cancellable_output(&mut process, cancellation, job)? else { + ) -> Result>, String> { + if !semantic_job_is_current(cancellation, job) { return Ok(None); - }; - Ok(Some(parse_check_output(&output))) + } + self.ensure_root(root)?; + let result = self.host.analyze_literal(style).map(|_| ()); + Ok(semantic_job_is_current(cancellation, job).then_some(result)) } - fn run_source_check( - &self, + fn analyze_pcx( + &mut self, root: &Path, - source: &str, + request: &PcxRequest, cancellation: &Cancellation, job: &SemanticJob, - ) -> Result, String>>, String> { - let temporary = TemporarySource::create(source)?; - let mut process = Command::new(&self.config.compiler); - process - .arg("--diagnostic-format") - .arg("json") - .arg("check") - .arg("--source") - .arg(&temporary.path) - .current_dir(root); - if self.config.seed { - process.arg("--seed"); - } else if let Some(config) = &self.config.config { - process.arg("--config").arg(config); - } - let Some(output) = cancellable_output(&mut process, cancellation, job)? else { + ) -> Result>, String> { + if !semantic_job_is_current(cancellation, job) { return Ok(None); - }; - Ok(Some(parse_check_output(&output))) + } + self.ensure_root(root)?; + let result = self.host.analyze_pcx(request).map(|_| ()); + Ok(semantic_job_is_current(cancellation, job).then_some(result)) } } @@ -300,214 +224,6 @@ fn semantic_job_is_current(cancellation: &Cancellation, job: &SemanticJob) -> bo .is_ok_and(|versions| versions.get(&job.uri) == Some(&job.version)) } -fn cancellable_output( - process: &mut Command, - cancellation: &Cancellation, - job: &SemanticJob, -) -> Result, String> { - if !semantic_job_is_current(cancellation, job) { - return Ok(None); - } - process.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = process - .spawn() - .map_err(|error| format!("cannot run pliego-cssc check: {error}"))?; - let mut stdout = child - .stdout - .take() - .ok_or("pliego-cssc stdout is unavailable")?; - let mut stderr = child - .stderr - .take() - .ok_or("pliego-cssc stderr is unavailable")?; - let stdout_reader = - thread::spawn(move || read_bounded(&mut stdout, MAX_TOOL_OUTPUT_BYTES, "stdout")); - let stderr_reader = - thread::spawn(move || read_bounded(&mut stderr, MAX_TOOL_OUTPUT_BYTES, "stderr")); - let deadline = Instant::now() + TOOL_PROCESS_TIMEOUT; - loop { - if !semantic_job_is_current(cancellation, job) { - if child - .try_wait() - .map_err(|error| format!("cannot poll stale pliego-cssc check: {error}"))? - .is_none() - { - child - .kill() - .map_err(|error| format!("cannot cancel stale pliego-cssc check: {error}"))?; - } - child - .wait() - .map_err(|error| format!("cannot reap stale pliego-cssc check: {error}"))?; - let _ = join_process_reader(stdout_reader, "stdout"); - let _ = join_process_reader(stderr_reader, "stderr"); - return Ok(None); - } - if Instant::now() >= deadline { - if child - .try_wait() - .map_err(|error| format!("cannot poll timed-out pliego-cssc check: {error}"))? - .is_none() - { - child - .kill() - .map_err(|error| format!("cannot stop timed-out pliego-cssc check: {error}"))?; - } - child - .wait() - .map_err(|error| format!("cannot reap timed-out pliego-cssc check: {error}"))?; - let _ = join_process_reader(stdout_reader, "stdout"); - let _ = join_process_reader(stderr_reader, "stderr"); - return Err("pliego-cssc check exceeded the 30 second deadline".into()); - } - if let Some(status) = child - .try_wait() - .map_err(|error| format!("cannot poll pliego-cssc check: {error}"))? - { - let stdout = join_process_reader(stdout_reader, "stdout")?; - let stderr = join_process_reader(stderr_reader, "stderr")?; - return Ok(Some(Output { - status, - stdout, - stderr, - })); - } - thread::sleep(SEMANTIC_PROCESS_POLL); - } -} - -fn bounded_process_output( - process: &mut Command, - operation: &str, - timeout: Duration, -) -> Result { - process.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = process - .spawn() - .map_err(|error| format!("cannot run {operation}: {error}"))?; - let mut stdout = child - .stdout - .take() - .ok_or_else(|| format!("{operation} stdout is unavailable"))?; - let mut stderr = child - .stderr - .take() - .ok_or_else(|| format!("{operation} stderr is unavailable"))?; - let stdout_reader = - thread::spawn(move || read_bounded(&mut stdout, MAX_TOOL_OUTPUT_BYTES, "stdout")); - let stderr_reader = - thread::spawn(move || read_bounded(&mut stderr, MAX_TOOL_OUTPUT_BYTES, "stderr")); - let deadline = Instant::now() + timeout; - loop { - if let Some(status) = child - .try_wait() - .map_err(|error| format!("cannot poll {operation}: {error}"))? - { - return Ok(Output { - status, - stdout: join_process_reader(stdout_reader, "stdout")?, - stderr: join_process_reader(stderr_reader, "stderr")?, - }); - } - if Instant::now() >= deadline { - child - .kill() - .map_err(|error| format!("cannot stop timed-out {operation}: {error}"))?; - child - .wait() - .map_err(|error| format!("cannot reap timed-out {operation}: {error}"))?; - let _ = join_process_reader(stdout_reader, "stdout"); - let _ = join_process_reader(stderr_reader, "stderr"); - return Err(format!("{operation} exceeded the 30 second deadline")); - } - thread::sleep(SEMANTIC_PROCESS_POLL); - } -} - -fn read_bounded(reader: &mut impl Read, maximum: usize, stream: &str) -> io::Result> { - let mut bytes = Vec::new(); - reader.take((maximum + 1) as u64).read_to_end(&mut bytes)?; - if bytes.len() > maximum { - return Err(io::Error::other(format!( - "pliego-cssc {stream} exceeds {maximum} bytes" - ))); - } - Ok(bytes) -} - -fn join_process_reader( - reader: thread::JoinHandle, io::Error>>, - stream: &str, -) -> Result, String> { - reader - .join() - .map_err(|_| format!("pliego-cssc {stream} reader panicked"))? - .map_err(|error| format!("cannot read pliego-cssc {stream}: {error}")) -} - -fn parse_check_output(output: &Output) -> Result, String> { - if output.status.success() { - if !output.stderr.is_empty() { - return Err("successful pliego-cssc check wrote stderr".into()); - } - return Ok(Vec::new()); - } - let document: Value = serde_json::from_slice(&output.stderr) - .map_err(|error| format!("invalid pliego-cssc diagnostic JSON: {error}"))?; - if document.get("schemaVersion") != Some(&json!(1)) - || document.get("command") != Some(&json!("check")) - { - return Err("unsupported pliego-cssc diagnostic schema".into()); - } - let diagnostics = document - .get("diagnostics") - .and_then(Value::as_array) - .ok_or("pliego-cssc diagnostic document has no diagnostics")?; - if diagnostics.is_empty() { - return Err("failed pliego-cssc check returned no diagnostics".into()); - } - Ok(diagnostics.clone()) -} - -struct TemporarySource { - path: PathBuf, -} - -impl TemporarySource { - fn create(source: &str) -> Result { - for _ in 0..16 { - let id = TEMP_SOURCE_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("pliegocss-lsp-{}-{id}.rs", std::process::id())); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - options.mode(0o600); - match options.open(&path) { - Ok(mut file) => { - if let Err(error) = file.write_all(source.as_bytes()) { - drop(file); - let _ = remove_file(&path); - return Err(format!("cannot write temporary Rust source: {error}")); - } - return Ok(Self { path }); - } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(format!("cannot create temporary Rust source: {error}")); - } - } - } - Err("cannot allocate a unique temporary Rust source path".into()) - } -} - -impl Drop for TemporarySource { - fn drop(&mut self) { - let _ = remove_file(&self.path); - } -} - struct SemanticJob { uri: String, version: i64, @@ -956,181 +672,108 @@ fn semantic_document_diagnostics( Some(values) } -struct SyntheticPcxSource { - source: String, - ranges: BTreeMap<(usize, usize), SourceRange>, - fallback: SourceRange, -} - -fn synthetic_pcx_source(report: &ScanReport) -> Result, String> { - let mut source = String::from("fn __pliegocss_lsp_pcx() {"); - let mut actual_ranges = Vec::new(); - let mut fallback = None; - for invocation in &report.invocations { - let InvocationKind::Pcx(pcx) = &invocation.kind else { - continue; - }; - if pcx.clauses.len() < 2 { - continue; - } - fallback.get_or_insert(invocation.range); - source.push_str("let _=pcx!("); - write!(source, "{:?}", pcx.base.value).expect("writing to String cannot fail"); - for clause in &pcx.clauses { - source.push_str(",match (){"); - for branch in &clause.branches { - source.push_str("_=>"); - write!(source, "{:?}", branch.style.value).expect("writing to String cannot fail"); - source.push(','); - actual_ranges.push(branch.style.range); - } - source.push('}'); - } - source.push_str(");"); - } - source.push('}'); - let Some(fallback) = fallback else { - return Ok(None); - }; - let synthetic = scan_source_named("pliegocss-lsp-pcx.rs", &source) - .map_err(|error| format!("cannot scan synthetic pcx source: {error}"))?; - if !synthetic.diagnostics.is_empty() { - return Err("synthetic pcx source produced scanner diagnostics".into()); - } - let mut synthetic_ranges = Vec::new(); - for invocation in &synthetic.invocations { - let InvocationKind::Pcx(pcx) = &invocation.kind else { - return Err("synthetic source produced a non-pcx invocation".into()); - }; - for clause in &pcx.clauses { - synthetic_ranges.extend(clause.branches.iter().map(|branch| branch.style.range)); - } - } - if synthetic_ranges.len() != actual_ranges.len() { - return Err("synthetic pcx literal mapping is incomplete".into()); - } - let ranges = synthetic_ranges - .into_iter() - .zip(actual_ranges) - .map(|(synthetic, actual)| ((synthetic.start.byte, synthetic.end.byte), actual)) - .collect(); - Ok(Some(SyntheticPcxSource { - source, - ranges, - fallback, - })) -} - fn pcx_document_diagnostics( engine: &mut SemanticEngine, job: &SemanticJob, report: &ScanReport, cancellation: &Cancellation, ) -> Option> { - let synthetic = match synthetic_pcx_source(report) { - Ok(Some(synthetic)) => synthetic, - Ok(None) => return Some(Vec::new()), - Err(error) => { - let mut value = diagnostic( - &job.text, - report.invocations[0].range, - "PCL001", - &format!("compiler-backed pcx diagnostics unavailable: {error}"), - 1, - ); - value["data"] = diagnostic_data("tool", &Value::Null, &Value::Null); - return Some(vec![value]); - } - }; - let findings = match engine.pcx_findings(&job.root, &synthetic.source, cancellation, job) { - Ok(Some(findings)) => findings, - Ok(None) => return None, - Err(error) => { - let mut value = diagnostic( - &job.text, - synthetic.fallback, - "PCL001", - &format!("compiler-backed pcx diagnostics unavailable: {error}"), - 1, - ); - value["data"] = diagnostic_data("tool", &Value::Null, &Value::Null); - return Some(vec![value]); - } - }; let mut diagnostics = Vec::new(); - for finding in findings { - if finding.get("code") != Some(&json!("PCX003")) { + for invocation in &report.invocations { + let InvocationKind::Pcx(pcx) = &invocation.kind else { continue; - } - match pcx_diagnostic(&job.text, &synthetic, finding) { - Ok(value) => diagnostics.push(value), + }; + let request = PcxRequest::new( + pcx.base.value.clone(), + pcx.clauses.iter().map(|clause| { + clause + .branches + .iter() + .map(|branch| branch.style.value.clone()) + }), + ); + let result = match engine.analyze_pcx(&job.root, &request, cancellation, job) { + Ok(Some(result)) => result, + Ok(None) => return None, Err(error) => { let mut value = diagnostic( &job.text, - synthetic.fallback, + invocation.range, "PCL001", - &format!("invalid compiler pcx diagnostic: {error}"), + &format!("in-process pcx diagnostics unavailable: {error}"), 1, ); value["data"] = diagnostic_data("tool", &Value::Null, &Value::Null); return Some(vec![value]); } + }; + match result { + Ok(()) | Err(PcxError::Base(_) | PcxError::Branch { .. }) => {} + Err(PcxError::Conflict(conflict)) => { + let literal = + &pcx.clauses[conflict.right_clause].branches[conflict.right_branch].style; + let slots = conflict + .slots + .iter() + .map(|slot| format!("{slot:?}")) + .collect::>() + .join(", "); + let mut value = diagnostic( + &job.text, + literal.range, + "PCX003", + &format!( + "independent clauses {} and {} can both assign [{slots}] under the same condition", + conflict.left_clause + 1, + conflict.right_clause + 1, + ), + 1, + ); + value["data"] = diagnostic_data( + "composition", + &json!("express the combined state space in one `match`"), + &Value::Null, + ); + diagnostics.push(value); + } + Err(PcxError::ExpansionLimit { maximum }) => { + let mut value = diagnostic( + &job.text, + invocation.range, + "PCX004", + &format!("pcx expands to more than {maximum} style combinations"), + 1, + ); + value["data"] = diagnostic_data("composition", &Value::Null, &Value::Null); + diagnostics.push(value); + } + Err(PcxError::EmptyClause { clause }) => { + let mut value = diagnostic( + &job.text, + invocation.range, + "PCX001", + &format!("pcx clause {} has no visible branches", clause + 1), + 1, + ); + value["data"] = diagnostic_data("composition", &Value::Null, &Value::Null); + diagnostics.push(value); + } + Err(error) => { + let mut value = diagnostic( + &job.text, + invocation.range, + "PCL001", + &format!("in-process pcx diagnostics unavailable: {error}"), + 1, + ); + value["data"] = diagnostic_data("tool", &Value::Null, &Value::Null); + diagnostics.push(value); + } } } Some(diagnostics) } -fn pcx_diagnostic( - source: &str, - synthetic: &SyntheticPcxSource, - finding: &Value, -) -> Result { - let origin = finding - .get("origin") - .ok_or("missing pcx diagnostic origin")?; - if string_field(origin, "kind")? != "rust" - || string_field(origin, "label")? != "pcx-cross-clause-conflict" - { - return Err("pcx diagnostic has an unexpected origin".into()); - } - let range = finding.get("range").ok_or("missing pcx diagnostic range")?; - let start = usize::try_from( - range - .get("byteStart") - .and_then(Value::as_u64) - .ok_or("missing pcx range start")?, - ) - .map_err(|_| "pcx range start is too large")?; - let end = usize::try_from( - range - .get("byteEnd") - .and_then(Value::as_u64) - .ok_or("missing pcx range end")?, - ) - .map_err(|_| "pcx range end is too large")?; - let actual = synthetic - .ranges - .get(&(start, end)) - .ok_or("pcx diagnostic range has no source mapping")?; - let severity = match string_field(finding, "severity")? { - "error" => 1, - "warning" => 2, - _ => return Err("unsupported pcx diagnostic severity".into()), - }; - let mut value = lsp_diagnostic( - &source_range_to_lsp(source, *actual), - string_field(finding, "code")?, - string_field(finding, "message")?, - severity, - ); - value["data"] = json!({ - "category":finding.get("category").cloned().unwrap_or(Value::Null), - "suggestion":finding.get("suggestion").cloned().unwrap_or(Value::Null), - "replacement":Value::Null - }); - Ok(value) -} - fn semantic_diagnostics( engine: &mut SemanticEngine, root: &Path, @@ -1139,88 +782,33 @@ fn semantic_diagnostics( cancellation: &Cancellation, job: &SemanticJob, ) -> Option> { - let findings = match engine.findings(root, &literal.value, cancellation, job) { - Ok(Some(findings)) => findings, + let result = match engine.analyze_literal(root, &literal.value, cancellation, job) { + Ok(Some(result)) => result, Ok(None) => return None, Err(error) => { let mut value = diagnostic( source, literal.range, "PCL001", - &format!("compiler-backed diagnostics unavailable: {error}"), + &format!("in-process diagnostics unavailable: {error}"), 1, ); value["data"] = diagnostic_data("tool", &Value::Null, &Value::Null); return Some(vec![value]); } }; - let mut diagnostics = Vec::with_capacity(findings.len()); - for finding in findings { - match semantic_diagnostic(source, literal, finding) { - Ok(diagnostic) => diagnostics.push(diagnostic), - Err(error) => { - let mut value = diagnostic( - source, - literal.range, - "PCL001", - &format!("invalid compiler diagnostic: {error}"), - 1, - ); - value["data"] = diagnostic_data("tool", &Value::Null, &Value::Null); - return Some(vec![value]); - } - } + match result { + Ok(()) => Some(Vec::new()), + Err(finding) => Some(vec![semantic_diagnostic(source, literal, &finding)]), } - Some(diagnostics) } -fn semantic_diagnostic( - source: &str, - literal: &StyleLiteral, - finding: &Value, -) -> Result { - let code = string_field(finding, "code")?; - let message = string_field(finding, "message")?; - let severity = match string_field(finding, "severity")? { - "error" => 1, - "warning" => 2, - "information" => 3, - "hint" => 4, - _ => return Err("unsupported severity".into()), - }; - let origin = finding.get("origin").ok_or("missing origin")?; - if string_field(origin, "kind")? != "cli" - || string_field(origin, "label")? != "explicit-style-1" - { - return Err("semantic diagnostic has an unexpected origin".into()); - } - let range = - if let Some(style_range) = finding.get("styleRange").filter(|value| !value.is_null()) { - let start = usize::try_from( - style_range - .get("byteStart") - .and_then(Value::as_u64) - .ok_or("missing style range start")?, - ) - .map_err(|_| "style range start is too large")?; - let end = usize::try_from( - style_range - .get("byteEnd") - .and_then(Value::as_u64) - .ok_or("missing style range end")?, - ) - .map_err(|_| "style range end is too large")?; - semantic_source_range(source, literal, start, end)? - } else { - source_range_to_lsp(source, literal.range) - }; - let mut diagnostic = lsp_diagnostic(&range, code, message, severity); - diagnostic["data"] = json!({ - "category":finding.get("category").cloned().unwrap_or(Value::Null), - "suggestion":finding.get("suggestion").cloned().unwrap_or(Value::Null), - "replacement":finding.get("replacement").cloned().unwrap_or(Value::Null) - }); - Ok(diagnostic) +fn semantic_diagnostic(source: &str, literal: &StyleLiteral, finding: &Diagnostic) -> Value { + let range = semantic_source_range(source, literal, finding.span.start, finding.span.end) + .unwrap_or_else(|_| source_range_to_lsp(source, literal.range)); + let mut value = lsp_diagnostic(&range, finding.code.as_str(), &finding.message, 1); + value["data"] = diagnostic_data("style", &json!(finding.suggestion), &Value::Null); + value } fn semantic_source_range( @@ -1269,7 +857,7 @@ fn completion(server: &mut Server, params: &Value) -> Result { let needle = &prefix[variant_end..]; let range = byte_range_to_lsp(&text, context.source_word_start..context.source_word_end); let mut items = Vec::new(); - for item in server.catalog()? { + for item in server.catalog() { let example = item.get("example").and_then(Value::as_str).unwrap_or(""); let pattern = item.get("pattern").and_then(Value::as_str).unwrap_or(""); let match_name = item.get("matchName").and_then(Value::as_str).unwrap_or(""); @@ -1294,32 +882,35 @@ fn completion(server: &mut Server, params: &Value) -> Result { fn hover(server: &mut Server, params: &Value) -> Result { let (uri, line, character) = text_position(params)?; - let text = &server + let text = server .documents .get(uri) .ok_or("document is not open")? - .text; - let context = - utility_context(uri, text, line, character)?.ok_or("cursor is not in a utility literal")?; + .text + .clone(); + let context = utility_context(uri, &text, line, character)? + .ok_or("cursor is not in a utility literal")?; let utility = &context.value[context.word_start..context.word_end]; if utility.is_empty() { return Ok(Value::Null); } - let explained = server.tool("explain", &["--style", utility])?; - if explained.get("schemaVersion") != Some(&json!(2)) { - return Err("unsupported explain schema".into()); - } - let item = explained - .get("utilities") - .and_then(Value::as_array) - .and_then(|items| items.first()) - .ok_or("explain returned no utility")?; - let summary = item.get("summary").and_then(Value::as_str).unwrap_or(""); - let pattern = item.get("pattern").and_then(Value::as_str).unwrap_or(""); - let css = explained.get("css").and_then(Value::as_str).unwrap_or(""); + let syntax = parse_style_list(utility).map_err(|error| error.to_string())?; + let item = syntax + .items + .first() + .ok_or("utility parser returned no item")?; + let descriptor = utility_descriptor_for_style_item(item) + .ok_or("compiler accepted a utility without catalog metadata")?; + let host = server.analysis()?; + let semantic = host + .analyze_literal(utility) + .map_err(|error| error.to_string())?; + let css = emit_css_with_theme(host.theme(), &semantic).map_err(|error| error.to_string())?; + let summary = descriptor.summary(); + let pattern = descriptor.pattern(); Ok(json!({ "contents":{"kind":"markdown","value":format!("**{utility}** — `{pattern}`\n\n{summary}\n\n```css\n{css}\n```")}, - "range":byte_range_to_lsp(text, context.source_word_start..context.source_word_end) + "range":byte_range_to_lsp(&text, context.source_word_start..context.source_word_end) })) } @@ -1597,14 +1188,12 @@ fn string_field<'a>(value: &'a Value, name: &str) -> Result<&'a str, String> { fn cache_identity(root: &Path, config: &ServerConfig) -> Result { let mut digest = Sha256::new(); digest.update(root.as_os_str().to_string_lossy().as_bytes()); - digest.update(b"\0compiler:"); - digest.update(config.compiler.to_string_lossy().as_bytes()); if config.seed { digest.update(b"\0seed"); - } else if let Some(path) = &config.config { + } else if let Some(path) = resolved_theme_path(root, config) { digest.update(b"\0config:"); digest.update(path.as_os_str().to_string_lossy().as_bytes()); - let metadata = std::fs::metadata(path) + let metadata = std::fs::metadata(&path) .map_err(|error| format!("cannot inspect config {}: {error}", path.display()))?; if metadata.len() > MAX_CONFIG_FINGERPRINT_BYTES { return Err(format!( @@ -1612,7 +1201,7 @@ fn cache_identity(root: &Path, config: &ServerConfig) -> Result path.display() )); } - let bytes = std::fs::read(path) + let bytes = std::fs::read(&path) .map_err(|error| format!("cannot read config {}: {error}", path.display()))?; digest.update(metadata.len().to_le_bytes()); digest.update(bytes); @@ -1622,6 +1211,35 @@ fn cache_identity(root: &Path, config: &ServerConfig) -> Result Ok(format!("{:x}", digest.finalize())) } +fn resolved_theme_path(root: &Path, config: &ServerConfig) -> Option { + if config.seed { + return None; + } + config.config.as_ref().map_or_else( + || { + let conventional = root.join("pliego.theme.toml"); + conventional.is_file().then_some(conventional) + }, + |path| { + Some(if path.is_absolute() { + path.clone() + } else { + root.join(path) + }) + }, + ) +} + +fn load_theme( + root: &Path, + config: &ServerConfig, +) -> Result { + resolved_theme_path(root, config).map_or_else( + || Ok(pliego_css_theme::ThemeRegistry::seed()), + |path| pliego_css_config::parse_path(&path).map_err(|error| error.to_string()), + ) +} + fn file_uri_path(uri: &str) -> Result { let encoded = uri.strip_prefix("file://").ok_or("URI must use file:")?; if encoded.contains(['?', '#']) { @@ -1731,20 +1349,6 @@ fn write_message(output: &mut impl Write, value: &Value) -> Result<(), String> { mod tests { use super::*; - #[test] - fn delegated_process_output_is_bounded() { - let mut within = io::Cursor::new(vec![b'x'; 8]); - assert_eq!(read_bounded(&mut within, 8, "stdout").unwrap().len(), 8); - - let mut excessive = io::Cursor::new(vec![b'x'; 9]); - assert!( - read_bounded(&mut excessive, 8, "stdout") - .unwrap_err() - .to_string() - .contains("exceeds 8 bytes") - ); - } - #[test] fn utf16_positions_round_trip_astral_scalars() { let source = "fn x() { let _ = \"😀 flex\"; }\n"; @@ -1781,19 +1385,19 @@ mod tests { #[test] fn semantic_ranges_are_exact_and_escaped_literals_fail_to_the_whole_token() { - let finding = json!({ - "code":"PCS001","category":"style","severity":"error", - "message":"unknown utility `unknown`","suggestion":null, - "origin":{"kind":"cli","label":"explicit-style-1"}, - "range":null,"styleRange":{"byteStart":5,"byteEnd":12},"replacement":null - }); + let finding = Diagnostic { + code: pliego_css_ir::DiagnosticCode::UnknownUtility, + message: "unknown utility `unknown`".into(), + span: pliego_css_ir::SourceSpan::new(5, 12), + suggestion: None, + }; let source = "fn x(){let _=pc!(\"flex unknown\");}"; let report = scan_source_named("x.rs", source).unwrap(); let literal = match &report.invocations[0].kind { InvocationKind::Pc(pc) => &pc.style, InvocationKind::Pcx(_) => unreachable!(), }; - let exact = semantic_diagnostic(source, literal, &finding).unwrap(); + let exact = semantic_diagnostic(source, literal, &finding); assert_eq!( exact["range"], byte_range_to_lsp( @@ -1808,7 +1412,7 @@ mod tests { InvocationKind::Pc(pc) => &pc.style, InvocationKind::Pcx(_) => unreachable!(), }; - let whole = semantic_diagnostic(escaped, literal, &finding).unwrap(); + let whole = semantic_diagnostic(escaped, literal, &finding); assert_eq!(whole["range"], source_range_to_lsp(escaped, literal.range)); let empty = "fn x(){let _=pc!(\"bg-[]\");}"; @@ -1823,29 +1427,31 @@ mod tests { } #[test] - fn synthetic_pcx_ranges_map_back_to_the_original_branch_token() { + fn in_process_pcx_ranges_target_the_original_branch_token() { let source = concat!( "fn x(){let _=pcx!(\"flex\",", "if a { r#\"opacity-50\"# } else { \"block\" },", "if b { \"opacity-50\" } else { \"grid\" });}" ); let report = scan_source_named("x.rs", source).unwrap(); - let synthetic = synthetic_pcx_source(&report).unwrap().unwrap(); let expected_start = source.rfind("\"opacity-50\"").unwrap(); let expected_end = expected_start + "\"opacity-50\"".len(); - let ((start, end), _) = synthetic - .ranges - .iter() - .find(|(_, actual)| actual.start.byte == expected_start) - .expect("mapped second-clause branch"); - let finding = json!({ - "code":"PCX003","category":"composition","severity":"error", - "message":"independent clauses 1 and 2 overlap", - "suggestion":"use one match", - "origin":{"kind":"rust","label":"pcx-cross-clause-conflict"}, - "range":{"byteStart":start,"byteEnd":end} + let job = SemanticJob { + uri: "x.rs".into(), + version: 1, + text: source.into(), + root: PathBuf::from("."), + }; + let cancellation = Arc::new(Mutex::new(BTreeMap::from([("x.rs".into(), 1)]))); + let mut engine = SemanticEngine::new(ServerConfig { + seed: true, + ..ServerConfig::default() }); - let value = pcx_diagnostic(source, &synthetic, &finding).unwrap(); + let values = pcx_document_diagnostics(&mut engine, &job, &report, &cancellation).unwrap(); + let value = values + .iter() + .find(|value| value["code"] == "PCX003") + .expect("shared pcx frontend reports conflict"); assert_eq!( value["range"], byte_range_to_lsp(source, expected_start..expected_end) @@ -1948,11 +1554,14 @@ mod tests { } #[test] - fn cache_identity_tracks_root_config_bytes_and_compiler() { + fn cache_identity_tracks_root_and_config_bytes_but_not_legacy_compiler() { let directory = std::env::temp_dir().join(format!( "pliego-lsp-cache-{}-{}", std::process::id(), - TEMP_SOURCE_COUNTER.fetch_add(1, Ordering::Relaxed) + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&directory).unwrap(); let config_path = directory.join("theme.json"); @@ -1968,7 +1577,7 @@ mod tests { let second = cache_identity(&directory, &config).unwrap(); assert_ne!(first, second); config.compiler = OsString::from("compiler-b"); - assert_ne!(second, cache_identity(&directory, &config).unwrap()); + assert_eq!(second, cache_identity(&directory, &config).unwrap()); assert_ne!( cache_identity(&directory, &config).unwrap(), cache_identity(&directory.join("other"), &config).unwrap() @@ -2008,13 +1617,10 @@ mod tests { version: 1, }, ); - server.catalog = Some(( - cache_identity(&server.root, &server.config).unwrap(), - vec![json!({ - "example":"gap-4","pattern":"gap-{space}","matchName":"gap", - "summary":"Set gap." - })], - )); + server.catalog = Some(vec![json!({ + "example":"gap-4","pattern":"gap-{space}","matchName":"gap", + "summary":"Set gap." + })]); let result = completion( &mut server, &json!({ diff --git a/crates/pliego-css-lsp/src/project_index.rs b/crates/pliego-css-lsp/src/project_index.rs index 0b83d9c..8f9cfca 100644 --- a/crates/pliego-css-lsp/src/project_index.rs +++ b/crates/pliego-css-lsp/src/project_index.rs @@ -659,7 +659,7 @@ mod tests { "physicalCoverage":"compiler-verified-complete", "styleIdFormatVersion":2, "classNameFormatVersion":1, - "themeIdFormatVersion":2, + "themeIdFormatVersion":3, "themeId":"theme","targets":"modern","format":"minified", "ruleSelection":"all-compiled", "assetPlanFile":"pliego.assets.json", diff --git a/crates/pliego-css-macros/src/lib.rs b/crates/pliego-css-macros/src/lib.rs index 82ab2bf..ec0b513 100644 --- a/crates/pliego-css-macros/src/lib.rs +++ b/crates/pliego-css-macros/src/lib.rs @@ -20,7 +20,6 @@ use syn::{ parse_macro_input, }; -use pliego_css_ir::SemanticStyle; use pliego_css_theme::ThemeRegistry; const THEME_PATH_ENV: &str = "PLIEGO_CSS_THEME_PATH"; @@ -52,12 +51,8 @@ pub fn pc_id(tokens: TokenStream) -> TokenStream { Ok(theme) => theme, Err(error) => return error, }; - let source = literal.value(); - let syntax = match pliego_css_parser::parse_style_list(&source) { - Ok(syntax) => syntax, - Err(diagnostic) => return compile_error(&literal, diagnostic), - }; - let semantic = match pliego_css_compiler::lower_style_with_theme(&theme, &syntax) { + let mut host = pliego_css_compiler::AnalysisHost::new((*theme).clone()); + let semantic = match host.analyze_literal(&literal.value()) { Ok(semantic) => semantic, Err(diagnostic) => return compile_error(&literal, diagnostic), }; @@ -93,11 +88,8 @@ impl Parse for ConditionalInput { } } -const MAX_PCX_COMBINATIONS: usize = 64; - struct CompiledBranch { literal: LitStr, - style: SemanticStyle, } struct CompiledClause { @@ -109,64 +101,94 @@ struct CompiledClause { /// Compiles every visible conditional combination and returns the selected identity bits. #[doc(hidden)] #[proc_macro] +#[allow(clippy::too_many_lines)] pub fn pcx_id(tokens: TokenStream) -> TokenStream { let ConditionalInput { base, clauses } = parse_macro_input!(tokens as ConditionalInput); let theme = match active_theme(&base) { Ok(theme) => theme, Err(error) => return error, }; - let base_style = match compile_visible_literal(&theme, &base) { - Ok(style) => style, - Err(error) => return error, - }; - let mut compiled_clauses = Vec::with_capacity(clauses.len()); for clause in clauses { - match compile_clause(&theme, clause) { + match compile_clause(clause) { Ok(clause) => compiled_clauses.push(clause), Err(error) => return error, } } - - if let Err(error) = validate_cross_clause_conflicts(&compiled_clauses) { - return error.into_compile_error().into(); - } - - let branch_counts = compiled_clauses - .iter() - .map(|clause| clause.branches.len()) - .collect::>(); - let combination_count = branch_counts.iter().try_fold(1_usize, |count, branches| { - count - .checked_mul(*branches) - .filter(|next| *next <= MAX_PCX_COMBINATIONS) - }); - if combination_count.is_none() { - let span = compiled_clauses - .last() - .map_or_else(|| base.span(), |clause| clause.span); - return syn::Error::new( - span, - format!( - "PCX004: `pcx!` expands to more than {MAX_PCX_COMBINATIONS} style combinations; collapse related state into one `match`" - ), - ) - .into_compile_error() - .into(); - } - - let combinations = cartesian_indices(&branch_counts); - let mut result_arms = Vec::with_capacity(combinations.len()); - for indices in combinations { - let mut style = base_style.clone(); - for (clause, branch_index) in compiled_clauses.iter().zip(&indices) { - style = pliego_css_compiler::compose_style_override_with_theme( - &theme, - style, - clause.branches[*branch_index].style.clone(), + let request = pliego_css_compiler::PcxRequest::new( + base.value(), + compiled_clauses + .iter() + .map(|clause| clause.branches.iter().map(|branch| branch.literal.value())), + ); + let mut host = pliego_css_compiler::AnalysisHost::new((*theme).clone()); + let analysis = match host.analyze_pcx(&request) { + Ok(analysis) => analysis, + Err(pliego_css_compiler::PcxError::Base(diagnostic)) => { + return compile_error(&base, diagnostic); + } + Err(pliego_css_compiler::PcxError::Branch { + clause, + branch, + diagnostic, + }) => { + return compile_error( + &compiled_clauses[clause].branches[branch].literal, + diagnostic, ); } - let id = style.id.get(); + Err(pliego_css_compiler::PcxError::Conflict(conflict)) => { + let slots = conflict + .slots + .iter() + .map(|slot| format!("{slot:?}")) + .collect::>() + .join(", "); + let literal = + &compiled_clauses[conflict.right_clause].branches[conflict.right_branch].literal; + return syn::Error::new_spanned( + literal, + format!( + "PCX003: independent clauses {} and {} can both assign [{slots}] under the same condition; express the combined state space in one `match`", + conflict.left_clause + 1, + conflict.right_clause + 1, + ), + ) + .into_compile_error() + .into(); + } + Err(pliego_css_compiler::PcxError::ExpansionLimit { maximum }) => { + let span = compiled_clauses + .last() + .map_or_else(|| base.span(), |clause| clause.span); + return syn::Error::new( + span, + format!( + "PCX004: `pcx!` expands to more than {maximum} style combinations; collapse related state into one `match`" + ), + ) + .into_compile_error() + .into(); + } + Err(pliego_css_compiler::PcxError::EmptyClause { clause }) => { + return syn::Error::new( + compiled_clauses[clause].span, + "PCX001: `pcx!` clause must expose at least one branch", + ) + .into_compile_error() + .into(); + } + Err(error) => { + return syn::Error::new(base.span(), error.to_string()) + .into_compile_error() + .into(); + } + }; + + let mut result_arms = Vec::with_capacity(analysis.combinations.len()); + for combination in analysis.combinations { + let indices = combination.selections; + let id = combination.semantic.id.get(); result_arms.push(quote! { (#(#indices,)*) => #id }); @@ -197,13 +219,10 @@ pub fn pcx_id(tokens: TokenStream) -> TokenStream { .into() } -fn compile_clause( - theme: &ThemeRegistry, - expression: Expr, -) -> core::result::Result { +fn compile_clause(expression: Expr) -> core::result::Result { match expression { - Expr::If(expression) => compile_if_clause(theme, expression), - Expr::Match(expression) => compile_match_clause(theme, expression), + Expr::If(expression) => compile_if_clause(expression), + Expr::Match(expression) => compile_match_clause(expression), other => Err(syn::Error::new_spanned( other, "`pcx!` clauses must be complete `if ... else ...` or `match ...` expressions", @@ -213,10 +232,7 @@ fn compile_clause( } } -fn compile_if_clause( - theme: &ThemeRegistry, - expression: ExprIf, -) -> core::result::Result { +fn compile_if_clause(expression: ExprIf) -> core::result::Result { let span = expression.span(); let ExprIf { cond, @@ -249,9 +265,6 @@ fn compile_if_clause( Err(error) => return Err(error.into_compile_error().into()), }; - let then_style = compile_visible_literal(theme, &then_literal)?; - let else_style = compile_visible_literal(theme, &else_literal)?; - Ok(CompiledClause { selector: quote! { if #cond { 0usize } else { 1usize } @@ -259,11 +272,9 @@ fn compile_if_clause( branches: vec![ CompiledBranch { literal: then_literal, - style: then_style, }, CompiledBranch { literal: else_literal, - style: else_style, }, ], span, @@ -271,7 +282,6 @@ fn compile_if_clause( } fn compile_match_clause( - theme: &ThemeRegistry, expression: ExprMatch, ) -> core::result::Result { let span = expression.span(); @@ -306,11 +316,10 @@ fn compile_match_clause( Ok(literal) => literal, Err(error) => return Err(error.into_compile_error().into()), }; - let style = compile_visible_literal(theme, &literal)?; selector_arms.push(quote! { #pat => #index }); - branches.push(CompiledBranch { literal, style }); + branches.push(CompiledBranch { literal }); } Ok(CompiledClause { @@ -324,54 +333,6 @@ fn compile_match_clause( }) } -fn validate_cross_clause_conflicts(clauses: &[CompiledClause]) -> Result<()> { - let semantic_clauses = clauses - .iter() - .map(|clause| { - clause - .branches - .iter() - .map(|branch| branch.style.clone()) - .collect::>() - }) - .collect::>(); - let Err(conflict) = pliego_css_compiler::analyze_cross_clause_conflicts(&semantic_clauses) - else { - return Ok(()); - }; - let slots = conflict - .slots - .iter() - .map(|slot| format!("{slot:?}")) - .collect::>() - .join(", "); - let literal = &clauses[conflict.right_clause].branches[conflict.right_branch].literal; - Err(syn::Error::new_spanned( - literal, - format!( - "PCX003: independent clauses {} and {} can both assign [{slots}] under the same condition; express the combined state space in one `match`", - conflict.left_clause + 1, - conflict.right_clause + 1, - ), - )) -} - -fn cartesian_indices(branch_counts: &[usize]) -> Vec> { - let mut combinations = vec![Vec::new()]; - for branch_count in branch_counts { - let mut next = Vec::with_capacity(combinations.len() * branch_count); - for prefix in combinations { - for branch in 0..*branch_count { - let mut combination = prefix.clone(); - combination.push(branch); - next.push(combination); - } - } - combinations = next; - } - combinations -} - fn visible_block_literal(block: &Block) -> Result { let [Stmt::Expr(expression, None)] = block.stmts.as_slice() else { return Err(syn::Error::new_spanned( @@ -396,17 +357,6 @@ fn visible_expression_literal(expression: &Expr) -> Result { Ok(literal.clone()) } -fn compile_visible_literal( - theme: &ThemeRegistry, - literal: &LitStr, -) -> core::result::Result { - let source = literal.value(); - let syntax = pliego_css_parser::parse_style_list(&source) - .map_err(|diagnostic| compile_error(literal, diagnostic))?; - pliego_css_compiler::lower_style_with_theme(theme, &syntax) - .map_err(|diagnostic| compile_error(literal, diagnostic)) -} - fn active_theme(literal: &LitStr) -> core::result::Result, TokenStream> { let theme = if let Some(path) = std::env::var_os(THEME_PATH_ENV) { let path = PathBuf::from(path); diff --git a/crates/pliego-css-ownership/README.md b/crates/pliego-css-ownership/README.md index e986fa2..1add237 100644 --- a/crates/pliego-css-ownership/README.md +++ b/crates/pliego-css-ownership/README.md @@ -18,4 +18,4 @@ Control Manifest measurements to routes. ## Stability -Published as exact-version public-preview tooling in `0.1.0-rc.2`; final `0.1.0` is not published. +Prepared as exact-version public-preview tooling for `0.1.0-rc.3`; final `0.1.0` is not published. diff --git a/crates/pliego-css-ownership/tests/contracts.rs b/crates/pliego-css-ownership/tests/contracts.rs index f7fd3c8..51cb4e8 100644 --- a/crates/pliego-css-ownership/tests/contracts.rs +++ b/crates/pliego-css-ownership/tests/contracts.rs @@ -20,7 +20,7 @@ fn sample_plan_value() -> Value { "applicationCoverage": "adapter-attested-complete", "styleIdFormatVersion": 1, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, + "themeIdFormatVersion": 3, "themeId": "0123456789abcdef0123456789abcdef", "targets": "modern", "format": "minified", diff --git a/crates/pliego-css-source/README.md b/crates/pliego-css-source/README.md index 5f6a769..52001d6 100644 --- a/crates/pliego-css-source/README.md +++ b/crates/pliego-css-source/README.md @@ -45,6 +45,14 @@ std::fs::write("target/pliego.reachability.json", reachability.as_bytes())?; # Ok::<(), Box>(()) ``` +Frameworks with a versioned wire seam can avoid reconstructing that graph. For +PliegoRS, parse the canonical `pliegors-product-topology/1` bytes with +`ProductTopology::from_json` and call `collect_css_inputs` with the closed Cargo +source inventory. This yields reachability bytes and the deterministic bundle +plan without linking PliegoRS crates. The convenience plan uses seed theme, +modern targets, and minified output; applications with other compiler policy +should apply the returned partitions through their normal bundle plan. + The collector recursively inventories bounded `.rs` inputs with portable paths, parses every unit with the existing Rust scanner, rejects malformed or unowned `pc!`/`pcx!` calls, and fails on stale exact-site declarations. `source_unit` is an explicit adapter attestation that every discovered diff --git a/crates/pliego-css-source/src/application.rs b/crates/pliego-css-source/src/application.rs index 50ff85a..f95c372 100644 --- a/crates/pliego-css-source/src/application.rs +++ b/crates/pliego-css-source/src/application.rs @@ -5,7 +5,8 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::{ScanDiagnostic, SourceParseError, scan_source_named}; @@ -17,6 +18,9 @@ const MAX_PATH_SEGMENTS: usize = 256; const MAX_ID_BYTES: usize = 256; const MAX_PATH_OR_NAME_BYTES: usize = 4 * 1024; +/// Framework-neutral wire-format identifier for product topology snapshots. +pub const PRODUCT_TOPOLOGY_SCHEMA: &str = "pliegors-product-topology/1"; + /// One framework component and the `PliegoCSS` source sites it owns. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ApplicationComponent { @@ -57,6 +61,31 @@ pub struct CollectedReachability { invocations: usize, } +/// Product topology decoded from a framework-owned canonical snapshot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProductTopology { + components: Vec, + routes: Vec, + islands: Vec, +} + +/// One deterministic physical source partition derived from product topology. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductBundle { + id: String, + sources: Vec, + emit_theme: bool, +} + +/// Reachability and bundle-plan inputs derived without linking a framework crate. +#[derive(Debug)] +pub struct ProductCssInputs { + reachability: CollectedReachability, + bundle_plan: Vec, + bundles: Vec, +} + /// Failure while validating topology, inventorying Rust, or collecting exact sites. #[non_exhaustive] #[derive(Debug)] @@ -85,6 +114,8 @@ pub enum CollectError { /// Scanner explanation. message: String, }, + /// A closed product topology snapshot could not be decoded. + Decode(serde_json::Error), /// Canonical JSON serialization failed. Serialize(serde_json::Error), } @@ -99,6 +130,45 @@ enum SourceOwnership { }, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProductTopologyDocument { + schema: String, + components: Vec, + routes: Vec, + islands: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProductTopologyComponent { + id: String, + source_units: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +struct ProductTopologyRoute { + id: String, + path: String, + components: Vec, + islands: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +struct ProductTopologyIsland { + id: String, + name: String, + components: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum ProductRoot { + Route(String), + Island(String), +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ReachabilityDocument { @@ -289,6 +359,259 @@ impl CollectedReachability { } } +impl ProductTopology { + /// Decodes one closed, bounded framework product topology snapshot. + /// + /// # Errors + /// + /// Returns an error for oversized, unknown, duplicate, unsafe, dangling, + /// or noncanonical topology content. + pub fn from_json(bytes: &[u8]) -> Result { + valid( + bytes.len() <= MAX_DOCUMENT_BYTES, + "product topology snapshot exceeds 16 MiB", + )?; + let document: ProductTopologyDocument = + serde_json::from_slice(bytes).map_err(CollectError::Decode)?; + valid( + document.schema == PRODUCT_TOPOLOGY_SCHEMA, + "unsupported product topology schema", + )?; + let topology = Self { + components: document.components, + routes: document.routes, + islands: document.islands, + }; + topology.validate()?; + Ok(topology) + } + + /// Collects canonical reachability and seed-policy physical bundle-plan inputs. + /// + /// The generated plan uses the fixture seed theme, modern targets, and + /// minified output. Applications with other policy should consume the + /// returned partitions through the normal bundle-plan surface. + /// + /// The optional source inventory must be the complete Cargo/rustc-attested + /// Rust source set. Without it, the registered component units define the + /// bounded scan roots. + /// + /// # Errors + /// + /// Returns an error when topology, source inventory, ownership, scanning, + /// or bundle planning fails closed. + pub fn collect_css_inputs( + &self, + project_root: impl AsRef, + source_units: Option<&[String]>, + ) -> Result { + self.validate()?; + if let Some(source_units) = source_units { + valid( + !source_units.is_empty(), + "Cargo source inventory cannot be empty", + )?; + } + let application = self.application_topology(source_units); + let reachability = application.collect(project_root)?; + let bundles = self.partition()?; + let bundle_plan = render_product_bundle_plan(&bundles)?; + Ok(ProductCssInputs { + reachability, + bundle_plan, + bundles, + }) + } + + fn validate(&self) -> Result<(), CollectError> { + valid( + !self.components.is_empty(), + "product topology has no components", + )?; + valid(!self.routes.is_empty(), "product topology has no routes")?; + let component_ids = canonical_product_components(&self.components)?; + let island_ids = canonical_product_islands(&self.islands, &component_ids)?; + canonical_product_routes(&self.routes, &component_ids, &island_ids)?; + let item_count = self + .components + .len() + .checked_add(self.routes.len()) + .and_then(|count| count.checked_add(self.islands.len())); + valid( + item_count.is_some_and(|count| count <= MAX_ITEMS), + "product topology exceeds 65,535 top-level items", + ) + } + + fn application_topology(&self, inventory: Option<&[String]>) -> ApplicationTopology { + let mut topology = ApplicationTopology::new(); + let mut source_roots = BTreeSet::new(); + for component in &self.components { + let mut application_component = ApplicationComponent::new(&component.id); + for source in &component.source_units { + application_component = application_component.source_unit(source); + source_roots.insert(source_parent(source)); + } + topology = topology.component(application_component); + } + if let Some(inventory) = inventory { + source_roots = inventory.iter().cloned().collect(); + } + for source_root in source_roots { + topology = topology.source_root(source_root); + } + for route in &self.routes { + let mut application_route = ApplicationRoute::new(&route.id, &route.path); + for component in &route.components { + application_route = application_route.component(component); + } + topology = topology.route(application_route); + } + for island in &self.islands { + let mut application_island = ApplicationIsland::new(&island.id, &island.name); + for component in &island.components { + application_island = application_island.component(component); + } + topology = topology.island(application_island); + } + topology + } + + fn partition(&self) -> Result, CollectError> { + let mut memberships = self + .components + .iter() + .map(|component| (component.id.as_str(), BTreeSet::new())) + .collect::>(); + for route in &self.routes { + for component in &route.components { + memberships + .get_mut(component.as_str()) + .ok_or_else(|| invalid(format!("unknown route component `{component}`")))? + .insert(ProductRoot::Route(route.id.clone())); + } + } + for island in &self.islands { + for component in &island.components { + memberships + .get_mut(component.as_str()) + .ok_or_else(|| invalid(format!("unknown island component `{component}`")))? + .insert(ProductRoot::Island(island.id.clone())); + } + } + let island_routes = self + .islands + .iter() + .map(|island| { + let routes = self + .routes + .iter() + .filter(|route| route.islands.contains(&island.id)) + .map(|route| route.id.clone()) + .collect::>(); + (island.id.as_str(), routes) + }) + .collect::>(); + let mut sources = BTreeMap::>::new(); + for component in &self.components { + let roots = memberships.get(component.id.as_str()).ok_or_else(|| { + invalid(format!("component `{}` has no membership", component.id)) + })?; + for source in &component.source_units { + sources + .entry(source.clone()) + .or_default() + .extend(roots.iter().cloned()); + } + } + let mut groups = BTreeMap::, BTreeSet>::new(); + for (source, roots) in sources { + groups.entry(roots).or_default().insert(source); + } + let route_ids = self + .routes + .iter() + .map(|route| route.id.clone()) + .collect::>(); + let mut bundles = BTreeMap::new(); + let mut universal = Vec::new(); + for (roots, sources) in groups { + let id = product_bundle_id(&roots, &route_ids); + valid( + !bundles.contains_key(&id), + format!("automatic bundle ID collision `{id}`"), + )?; + let selected_routes = selected_route_ids(&roots, &island_routes); + if selected_routes == route_ids { + universal.push(id.clone()); + } + bundles.insert( + id.clone(), + ProductBundle { + id, + sources: sources.into_iter().collect(), + emit_theme: false, + }, + ); + } + universal.sort(); + let theme_bundle = universal.first().ok_or_else(|| { + invalid("automatic partition requires one bundle shared by every route") + })?; + bundles + .get_mut(theme_bundle) + .expect("selected theme bundle came from the bundle map") + .emit_theme = true; + Ok(bundles.into_values().collect()) + } +} + +impl ProductBundle { + /// Returns the portable physical bundle ID. + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + /// Returns the exact source-unit partition. + #[must_use] + pub fn sources(&self) -> &[String] { + &self.sources + } + + /// Reports whether this bundle owns theme emission. + #[must_use] + pub const fn emits_theme(&self) -> bool { + self.emit_theme + } +} + +impl ProductCssInputs { + /// Returns the collected canonical reachability document. + #[must_use] + pub const fn reachability(&self) -> &CollectedReachability { + &self.reachability + } + + /// Returns the canonical declarative bundle plan. + #[must_use] + pub fn bundle_plan(&self) -> &[u8] { + &self.bundle_plan + } + + /// Returns deterministic physical source partitions. + #[must_use] + pub fn bundles(&self) -> &[ProductBundle] { + &self.bundles + } + + /// Consumes the generated inputs into independently owned artifacts. + #[must_use] + pub fn into_parts(self) -> (CollectedReachability, Vec, Vec) { + (self.reachability, self.bundle_plan, self.bundles) + } +} + impl fmt::Display for CollectError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -304,6 +627,7 @@ impl fmt::Display for CollectError { column, message, } => write!(formatter, "{code}: {message} at {source}:{line}:{column}"), + Self::Decode(source) => write!(formatter, "cannot decode product topology: {source}"), Self::Serialize(source) => write!(formatter, "cannot serialize reachability: {source}"), } } @@ -314,7 +638,7 @@ impl std::error::Error for CollectError { match self { Self::Io { source, .. } => Some(source), Self::Parse(source) => Some(source), - Self::Serialize(source) => Some(source), + Self::Decode(source) | Self::Serialize(source) => Some(source), Self::Invalid(_) | Self::Diagnostic { .. } => None, } } @@ -563,6 +887,266 @@ fn render_document( Ok(bytes) } +fn canonical_product_components( + components: &[ProductTopologyComponent], +) -> Result, CollectError> { + let mut ids = BTreeSet::new(); + let mut source_keys = BTreeMap::new(); + for component in components { + validate_product_id(&component.id)?; + valid( + ids.insert(component.id.clone()), + format!("duplicate product component `{}`", component.id), + )?; + valid( + !component.source_units.is_empty(), + format!("product component `{}` has no source units", component.id), + )?; + let mut sources = BTreeSet::new(); + for source in &component.source_units { + valid( + is_portable_source_path(source) + && Path::new(source) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")), + format!("invalid product source unit `{source}`"), + )?; + valid( + sources.insert(source), + format!( + "product component `{}` repeats source unit `{source}`", + component.id + ), + )?; + let portable_key = source.to_lowercase(); + valid( + source_keys + .insert(portable_key, source.as_str()) + .is_none_or(|existing| existing == source), + format!("portable product source-unit collision `{source}`"), + )?; + } + } + Ok(ids) +} + +fn canonical_product_islands( + islands: &[ProductTopologyIsland], + component_ids: &BTreeSet, +) -> Result, CollectError> { + let mut ids = BTreeSet::new(); + let mut names = BTreeSet::new(); + for island in islands { + validate_product_id(&island.id)?; + validate_text(&island.name, MAX_ID_BYTES, "product island name")?; + valid( + ids.insert(island.id.clone()), + format!("duplicate product island `{}`", island.id), + )?; + valid( + names.insert(island.name.as_str()), + format!("duplicate rendered island name `{}`", island.name), + )?; + validate_product_references( + &island.components, + component_ids, + &format!("island `{}`", island.id), + )?; + } + Ok(ids) +} + +fn canonical_product_routes( + routes: &[ProductTopologyRoute], + component_ids: &BTreeSet, + island_ids: &BTreeSet, +) -> Result<(), CollectError> { + let mut ids = BTreeSet::new(); + let mut paths = BTreeSet::new(); + for route in routes { + validate_product_id(&route.id)?; + valid( + route.path.starts_with('/') + && route.path.len() <= MAX_PATH_OR_NAME_BYTES + && !route.path.contains(['\\', '?', '#', '\0']) + && !route.path.contains("//") + && route + .path + .split('/') + .skip(1) + .all(|segment| segment != "." && segment != ".."), + format!("invalid product route path `{}`", route.path), + )?; + valid( + ids.insert(route.id.as_str()), + format!("duplicate product route `{}`", route.id), + )?; + valid( + paths.insert(route.path.as_str()), + format!("duplicate product route path `{}`", route.path), + )?; + validate_product_references( + &route.components, + component_ids, + &format!("route `{}`", route.id), + )?; + let mut seen = BTreeSet::new(); + for island in &route.islands { + validate_product_id(island)?; + valid( + island_ids.contains(island), + format!("route `{}` references unknown island `{island}`", route.id), + )?; + valid( + seen.insert(island), + format!("route `{}` repeats island `{island}`", route.id), + )?; + } + } + Ok(()) +} + +fn validate_product_references( + references: &[String], + known: &BTreeSet, + owner: &str, +) -> Result<(), CollectError> { + valid(!references.is_empty(), format!("{owner} has no components"))?; + let mut seen = BTreeSet::new(); + for reference in references { + validate_product_id(reference)?; + valid( + known.contains(reference), + format!("{owner} references unknown component `{reference}`"), + )?; + valid( + seen.insert(reference), + format!("{owner} repeats component `{reference}`"), + )?; + } + Ok(()) +} + +fn validate_product_id(value: &str) -> Result<(), CollectError> { + valid( + !value.is_empty() + && value.len() <= MAX_ID_BYTES + && value.trim() == value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte)), + format!("invalid product ID `{value}`"), + ) +} + +fn source_parent(source: &str) -> String { + Path::new(source) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .and_then(Path::to_str) + .unwrap_or(source) + .replace('\\', "/") +} + +fn product_bundle_id(roots: &BTreeSet, route_ids: &BTreeSet) -> String { + if roots.is_empty() { + return "unreachable".to_owned(); + } + let routes = roots + .iter() + .filter_map(|root| match root { + ProductRoot::Route(id) => Some(id.as_str()), + ProductRoot::Island(_) => None, + }) + .collect::>(); + let islands = roots + .iter() + .filter_map(|root| match root { + ProductRoot::Island(id) => Some(id.as_str()), + ProductRoot::Route(_) => None, + }) + .collect::>(); + if islands.is_empty() + && routes.len() == route_ids.len() + && routes.iter().all(|route| route_ids.contains(*route)) + { + return "shared".to_owned(); + } + if routes.len() == 1 && islands.is_empty() { + return physical_bundle_id("route", routes[0]); + } + if routes.is_empty() && islands.len() == 1 { + return physical_bundle_id("island", islands[0]); + } + let mut digest = Sha256::new(); + for root in roots { + match root { + ProductRoot::Route(id) => digest.update(format!("route\0{id}\0")), + ProductRoot::Island(id) => digest.update(format!("island\0{id}\0")), + } + } + format!("shared-{}", &format!("{:x}", digest.finalize())[..12]) +} + +fn selected_route_ids( + roots: &BTreeSet, + island_routes: &BTreeMap<&str, BTreeSet>, +) -> BTreeSet { + let mut selected = BTreeSet::new(); + for root in roots { + match root { + ProductRoot::Route(id) => { + selected.insert(id.clone()); + } + ProductRoot::Island(id) => { + if let Some(routes) = island_routes.get(id.as_str()) { + selected.extend(routes.iter().cloned()); + } + } + } + } + selected +} + +fn physical_bundle_id(kind: &str, product_id: &str) -> String { + let simple = product_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !product_id.starts_with('-') + && !product_id.ends_with('-') + && !product_id.contains("--"); + if simple && kind.len() + product_id.len() < 64 { + return format!("{kind}-{product_id}"); + } + let digest = Sha256::digest(format!("{kind}\0{product_id}").as_bytes()); + format!("{kind}-{}", &format!("{digest:x}")[..12]) +} + +fn render_product_bundle_plan(bundles: &[ProductBundle]) -> Result, CollectError> { + let mut output = String::from( + "schema = 1\ntargets = \"modern\"\nformat = \"minified\"\n\n[theme]\nkind = \"seed\"\n", + ); + for bundle in bundles { + output.push_str("\n[bundles."); + output.push_str(&bundle.id); + output.push_str("]\nsources = ["); + for (index, source) in bundle.sources.iter().enumerate() { + if index > 0 { + output.push_str(", "); + } + output.push_str(&serde_json::to_string(source).map_err(CollectError::Serialize)?); + } + output.push_str("]\nemit-theme = "); + output.push_str(if bundle.emit_theme { "true" } else { "false" }); + output.push('\n'); + } + valid( + output.len() <= MAX_DOCUMENT_BYTES, + "bundle plan exceeds 16 MiB", + )?; + Ok(output.into_bytes()) +} + fn canonical_source_roots(source_roots: &[String]) -> Result, CollectError> { valid( !source_roots.is_empty(), @@ -1038,6 +1622,136 @@ mod tests { ); } + #[test] + fn product_topology_snapshot_collects_without_linking_framework_types() { + let fixture = Fixture::new(); + fixture.write("src/global.rs", "fn global() { let _ = pc!(\"block\"); }\n"); + fixture.write("src/home.rs", "fn home() { let _ = pc!(\"grid\"); }\n"); + fixture.write( + "src/counter.rs", + "fn counter() { let _ = pc!(\"flex\"); }\n", + ); + let topology = ProductTopology::from_json( + br#"{ + "schema": "pliegors-product-topology/1", + "components": [ + {"id":"app::counter","sourceUnits":["src/counter.rs"]}, + {"id":"app::global","sourceUnits":["src/global.rs"]}, + {"id":"app::home","sourceUnits":["src/home.rs"]} + ], + "routes": [ + {"id":"Home:Route","path":"/","components":["app::global","app::home"],"islands":["counter"]} + ], + "islands": [ + {"id":"counter","name":"visit-counter","components":["app::counter"]} + ] +} +"#, + ) + .unwrap(); + let generated = topology.collect_css_inputs(&fixture.0, None).unwrap(); + assert_eq!(generated.reachability().source_file_count(), 3); + assert_eq!(generated.reachability().invocation_count(), 3); + assert!( + generated + .bundles() + .iter() + .all(|bundle| bundle.id() != "route-Home:Route") + ); + assert!( + generated + .bundles() + .iter() + .any(|bundle| bundle.id() == "island-counter") + ); + assert_eq!( + generated + .bundles() + .iter() + .filter(|bundle| bundle.emits_theme()) + .count(), + 1 + ); + assert!(String::from_utf8_lossy(generated.bundle_plan()).contains("[bundles.shared]")); + } + + #[test] + fn product_topology_rejects_unknown_route_islands() { + let error = ProductTopology::from_json( + br#"{"schema":"pliegors-product-topology/1","components":[{"id":"app","sourceUnits":["src/app.rs"]}],"routes":[{"id":"home","path":"/","components":["app"],"islands":["missing"]}],"islands":[]}"#, + ) + .unwrap_err(); + assert!(error.to_string().contains("unknown island")); + } + + #[test] + fn product_topology_counts_route_island_occurrences_for_shared_selection() { + let fixture = Fixture::new(); + fixture.write("src/home.rs", "fn home() { let _ = pc!(\"grid\"); }\n"); + fixture.write("src/visit.rs", "fn visit() { let _ = pc!(\"block\"); }\n"); + fixture.write( + "src/counter.rs", + "fn counter() { let _ = pc!(\"flex\"); }\n", + ); + let topology = ProductTopology::from_json( + br#"{ + "schema":"pliegors-product-topology/1", + "components":[ + {"id":"counter","sourceUnits":["src/counter.rs"]}, + {"id":"home","sourceUnits":["src/home.rs"]}, + {"id":"visit","sourceUnits":["src/visit.rs"]} + ], + "routes":[ + {"id":"home","path":"/","components":["home"],"islands":["counter"]}, + {"id":"visit","path":"/visit","components":["visit"],"islands":["counter"]} + ], + "islands":[{"id":"counter","name":"counter","components":["counter"]}] +}"#, + ) + .unwrap(); + let generated = topology.collect_css_inputs(&fixture.0, None).unwrap(); + assert_eq!( + generated + .bundles() + .iter() + .filter(|bundle| bundle.emits_theme()) + .map(ProductBundle::id) + .collect::>(), + ["island-counter"] + ); + } + + #[test] + fn product_topology_hashes_long_ids_and_rejects_portable_source_aliases() { + let long_id = "a".repeat(256); + let json = format!( + "{{\"schema\":\"pliegors-product-topology/1\",\"components\":[{{\"id\":\"app\",\"sourceUnits\":[\"src/app.rs\"]}}],\"routes\":[{{\"id\":\"{long_id}\",\"path\":\"/\",\"components\":[\"app\"],\"islands\":[]}}],\"islands\":[]}}" + ); + let topology = ProductTopology::from_json(json.as_bytes()).unwrap(); + let bundles = topology.partition().unwrap(); + assert!(bundles.iter().all(|bundle| bundle.id().len() <= 64)); + + let aliases = ProductTopology::from_json( + br#"{"schema":"pliegors-product-topology/1","components":[{"id":"app","sourceUnits":["src/App.rs","src/app.rs"]}],"routes":[{"id":"home","path":"/","components":["app"],"islands":[]}],"islands":[]}"#, + ) + .unwrap_err(); + assert!( + aliases + .to_string() + .contains("portable product source-unit collision") + ); + } + + #[test] + fn malformed_product_topology_reports_decoding() { + let error = ProductTopology::from_json(b"{").unwrap_err(); + assert!( + error + .to_string() + .starts_with("cannot decode product topology:") + ); + } + #[test] fn rejects_unsafe_inventory_paths_and_duplicate_topology() { let fixture = Fixture::new(); diff --git a/crates/pliego-css-source/src/lib.rs b/crates/pliego-css-source/src/lib.rs index 2ce749a..1f8ffeb 100644 --- a/crates/pliego-css-source/src/lib.rs +++ b/crates/pliego-css-source/src/lib.rs @@ -37,7 +37,8 @@ use syn::{Arm, Block, Expr, ExprIf, ExprLit, ExprMatch, Lit, LitStr, Stmt, Token pub use application::{ ApplicationComponent, ApplicationIsland, ApplicationRoute, ApplicationTopology, CollectError, - CollectedReachability, + CollectedReachability, PRODUCT_TOPOLOGY_SCHEMA, ProductBundle, ProductCssInputs, + ProductTopology, }; pub use discovery::discover_migration_project; pub use formatting::{ diff --git a/crates/pliego-css-source/src/migration.rs b/crates/pliego-css-source/src/migration.rs index 6787f16..e09b2d8 100644 --- a/crates/pliego-css-source/src/migration.rs +++ b/crates/pliego-css-source/src/migration.rs @@ -35,7 +35,7 @@ const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; pub enum MigrationSourceKind { /// Sass/SCSS source. Sass, - /// Tailwind CSS v4 entry CSS. + /// Tailwind CSS entry source, including bounded v3 directives and v4 CSS-first syntax. Tailwind, /// CSS Modules source. CssModules, @@ -86,7 +86,7 @@ pub enum MigrationPreflightReliance { NotObserved, /// The full Tailwind import implicitly enables Preflight. Implicit, - /// The dedicated Preflight import was observed. + /// The dedicated Preflight import or Tailwind v3 `@tailwind base` directive was observed. Explicit, /// Both full and dedicated Preflight imports were observed. Mixed, @@ -2048,6 +2048,11 @@ pub fn build_reversible_migration_plan( "reversible migration plan inventories must exactly cover declared sources", )); } + if sources.is_empty() { + return Err(MigrationInventoryError::new( + "reversible migration plan requires at least one declared source", + )); + } let document = ReversibleMigrationPlanDocument { schema_version: 1, mode: "inventory-only", @@ -4301,10 +4306,26 @@ fn inventory_tailwind( MigrationDisposition::Static, &mut output, )?; + scan_marker( + source, + &masks.code, + "@tailwind", + "tailwind-layer", + MigrationDisposition::Static, + &mut output, + )?; for item in &mut output { if item.kind == "tailwind-source" && item.syntax.contains("inline(") { item.disposition = MigrationDisposition::Dynamic; } + if item.kind == "tailwind-layer" + && !matches!( + item.syntax.trim(), + "@tailwind base;" | "@tailwind components;" | "@tailwind utilities;" + ) + { + item.disposition = MigrationDisposition::Unsupported; + } } Ok(output) } @@ -4536,9 +4557,10 @@ fn derive_preflight( && (item.syntax.contains("\"tailwindcss\"") || item.syntax.contains("'tailwindcss'")) }); let explicit = constructs.iter().any(|item| { - item.kind == "tailwind-import" + (item.kind == "tailwind-import" && (item.syntax.contains("\"tailwindcss/preflight\"") - || item.syntax.contains("'tailwindcss/preflight'")) + || item.syntax.contains("'tailwindcss/preflight'"))) + || (item.kind == "tailwind-layer" && item.syntax.trim() == "@tailwind base;") }); match (implicit, explicit) { (false, false) => MigrationPreflightReliance::NotObserved, @@ -4583,6 +4605,46 @@ mod tests { ); } + #[test] + fn inventories_tailwind_v3_layers_and_explicit_base_preflight() { + let source = + "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n@tailwind variants;\n"; + let inventory = + inventory_migration_source(MigrationSourceKind::Tailwind, "src/tailwind.css", source) + .unwrap(); + assert_eq!( + inventory.preflight_reliance(), + MigrationPreflightReliance::Explicit + ); + assert_eq!(inventory.constructs().len(), 4); + assert_eq!(inventory.dynamic_count(), 0); + assert_eq!(inventory.unsupported_count(), 1); + assert!( + inventory + .constructs() + .iter() + .take(3) + .all(|item| item.kind() == "tailwind-layer" + && item.disposition() == MigrationDisposition::Static) + ); + assert_eq!( + inventory.constructs()[3].disposition(), + MigrationDisposition::Unsupported + ); + + let utilities_only = inventory_migration_source( + MigrationSourceKind::Tailwind, + "src/utilities.css", + "@tailwind utilities;\n", + ) + .unwrap(); + assert_eq!( + utilities_only.preflight_reliance(), + MigrationPreflightReliance::NotObserved + ); + assert_eq!(utilities_only.unsupported_count(), 0); + } + #[test] fn inventories_sass_without_treating_comments_as_code() { let source = r#"// @import "ignored"; diff --git a/crates/pliego-css-source/tests/reversible_migration.rs b/crates/pliego-css-source/tests/reversible_migration.rs index f27881a..e2638cb 100644 --- a/crates/pliego-css-source/tests/reversible_migration.rs +++ b/crates/pliego-css-source/tests/reversible_migration.rs @@ -10,6 +10,13 @@ use pliego_css_source::{ rollback_reversible_replacement_group, }; +#[test] +fn reversible_plan_rejects_an_empty_public_project_without_panicking() { + let error = build_reversible_migration_plan(&MigrationProject::new(), &[]) + .expect_err("empty public project must fail closed"); + assert!(error.to_string().contains("at least one declared source")); +} + #[test] fn reversible_plan_binds_inventory_and_refuses_automatic_edits() { let project = MigrationProject::new().source(MigrationProjectSource::new( diff --git a/crates/pliego-css-theme/src/binary.rs b/crates/pliego-css-theme/src/binary.rs index 5c2f37f..151da94 100644 --- a/crates/pliego-css-theme/src/binary.rs +++ b/crates/pliego-css-theme/src/binary.rs @@ -12,7 +12,7 @@ use super::{ pub const THEME_BINARY_MAGIC: [u8; 8] = *b"PLGCTHM\0"; /// Current canonical theme artifact format version. -pub const THEME_BINARY_FORMAT_VERSION: u16 = 2; +pub const THEME_BINARY_FORMAT_VERSION: u16 = 3; /// Maximum accepted size of one encoded theme artifact (16 MiB). pub const MAX_THEME_BINARY_BYTES: usize = 16 * 1024 * 1024; @@ -197,6 +197,7 @@ pub(super) fn encode(registry: &ThemeRegistry) -> Result, ThemeBinaryErr push_count(&mut output, registry.breakpoints().len()); for breakpoint in registry.breakpoints() { output.extend_from_slice(&breakpoint.id.get().to_be_bytes()); + output.extend_from_slice(&breakpoint.cascade_rank.to_be_bytes()); push_text(&mut output, "breakpoint name length", &breakpoint.name)?; push_text( &mut output, @@ -249,9 +250,10 @@ pub(super) fn decode(bytes: &[u8]) -> Result { let mut breakpoints = Vec::with_capacity(breakpoint_count); for _ in 0..breakpoint_count { let id = BreakpointId::new(u16::from_be_bytes(reader.read_array()?)); + let cascade_rank = u16::from_be_bytes(reader.read_array()?); let name = reader.read_text("breakpoint name")?; let min_width = reader.read_text("breakpoint minimum width")?; - breakpoints.push(BreakpointDefinition::new(id, name, min_width)); + breakpoints.push(BreakpointDefinition::new(id, cascade_rank, name, min_width)); } if reader.remaining() != 0 { @@ -289,7 +291,7 @@ fn encoded_size(registry: &ThemeRegistry) -> Result { "breakpoint minimum width length", breakpoint.min_width.len(), )?; - size = checked_size_add(size, 2 + 4 + 4)?; + size = checked_size_add(size, 2 + 2 + 4 + 4)?; size = checked_size_add(size, breakpoint.name.len())?; size = checked_size_add(size, breakpoint.min_width.len())?; } @@ -434,8 +436,8 @@ mod tests { TokenDefinition::new(TokenKind::Spacing, "gutter", "1.5rem"), ]; let mut breakpoints = vec![ - BreakpointDefinition::new(BreakpointId::new(4), "wide", "80rem"), - BreakpointDefinition::new(BreakpointId::new(3), "tablet", "52rem"), + BreakpointDefinition::new(BreakpointId::new(4), 4, "wide", "80rem"), + BreakpointDefinition::new(BreakpointId::new(3), 3, "tablet", "52rem"), ]; if reverse { tokens.reverse(); diff --git a/crates/pliego-css-theme/src/lib.rs b/crates/pliego-css-theme/src/lib.rs index 157f7ae..4c73544 100644 --- a/crates/pliego-css-theme/src/lib.rs +++ b/crates/pliego-css-theme/src/lib.rs @@ -21,7 +21,7 @@ pub use binary::{ }; /// Version of the canonical byte stream used to derive [`ThemeId`]. -pub const THEME_ID_FORMAT_VERSION: u16 = 2; +pub const THEME_ID_FORMAT_VERSION: u16 = 3; const THEME_ID_STREAM_DOMAIN: &[u8; 16] = b"pliego-theme-id\0"; const THEME_ID_TOKENS: u8 = 0x01; @@ -104,6 +104,11 @@ impl TokenDefinition { pub struct BreakpointDefinition { /// Stable breakpoint identifier. pub id: BreakpointId, + /// Total cascade order, from narrowest to widest. + /// + /// This is intentionally independent from [`Self::id`]: stable lookup + /// identity must not determine responsive precedence. + pub cascade_rank: u16, /// Human-readable variant name. pub name: String, /// Canonical CSS minimum width. @@ -112,9 +117,15 @@ pub struct BreakpointDefinition { impl BreakpointDefinition { /// Defines a responsive breakpoint. - pub fn new(id: BreakpointId, name: impl Into, min_width: impl Into) -> Self { + pub fn new( + id: BreakpointId, + cascade_rank: u16, + name: impl Into, + min_width: impl Into, + ) -> Self { Self { id, + cascade_rank, name: name.into(), min_width: min_width.into(), } @@ -157,6 +168,15 @@ pub enum ThemeError { /// Lexicographically second name assigned to the ID. second: String, }, + /// A cascade rank is assigned to two breakpoints. + DuplicateBreakpointCascadeRank { + /// Repeated cascade rank. + rank: u16, + /// Lexicographically first name assigned to the rank. + first: String, + /// Lexicographically second name assigned to the rank. + second: String, + }, /// A breakpoint attempts to shadow a built-in non-responsive condition. ReservedBreakpointName { /// Reserved condition name. @@ -195,6 +215,14 @@ impl fmt::Display for ThemeError { "duplicate breakpoint ID {} assigned to `{first}` and `{second}`", id.get() ), + Self::DuplicateBreakpointCascadeRank { + rank, + first, + second, + } => write!( + formatter, + "duplicate breakpoint cascade rank {rank} assigned to `{first}` and `{second}`" + ), Self::ReservedBreakpointName { name } => write!( formatter, "breakpoint name `{name}` is reserved by a built-in condition variant" @@ -270,6 +298,7 @@ impl ThemeRegistry { let mut breakpoint_names = BTreeMap::new(); let mut breakpoint_ids = BTreeMap::new(); + let mut breakpoint_ranks = BTreeMap::new(); for (index, breakpoint) in breakpoints.iter().enumerate() { if is_reserved_condition_variant(&breakpoint.name) { return Err(ThemeError::ReservedBreakpointName { @@ -291,6 +320,13 @@ impl ThemeRegistry { second: breakpoint.name.clone(), }); } + if let Some(previous) = breakpoint_ranks.insert(breakpoint.cascade_rank, index) { + return Err(ThemeError::DuplicateBreakpointCascadeRank { + rank: breakpoint.cascade_rank, + first: breakpoints[previous].name.clone(), + second: breakpoint.name.clone(), + }); + } } let id = derive_theme_id(&tokens, &breakpoints); @@ -724,6 +760,7 @@ fn encode_theme_identity( for breakpoint in breakpoints { stream.push(THEME_ID_BREAKPOINT); stream.extend_from_slice(&breakpoint.id.get().to_be_bytes()); + stream.extend_from_slice(&breakpoint.cascade_rank.to_be_bytes()); push_identity_text(&mut stream, &breakpoint.name); push_identity_text(&mut stream, &breakpoint.min_width); } @@ -872,9 +909,9 @@ fn extend_tokens(output: &mut Vec, kind: TokenKind, definitions fn seed_breakpoints() -> Vec { vec![ - BreakpointDefinition::new(BreakpointId::new(0), "sm", "40rem"), - BreakpointDefinition::new(BreakpointId::new(1), "md", "48rem"), - BreakpointDefinition::new(BreakpointId::new(2), "lg", "64rem"), + BreakpointDefinition::new(BreakpointId::new(0), 0, "sm", "40rem"), + BreakpointDefinition::new(BreakpointId::new(1), 1, "md", "48rem"), + BreakpointDefinition::new(BreakpointId::new(2), 2, "lg", "64rem"), ] } @@ -893,6 +930,7 @@ mod tests { ], vec![BreakpointDefinition::new( BreakpointId::new(0), + 0, "sm", "40rem", )], @@ -978,8 +1016,8 @@ mod tests { let duplicate_name = ThemeRegistry::from_definitions( [], [ - BreakpointDefinition::new(BreakpointId::new(0), "sm", "40rem"), - BreakpointDefinition::new(BreakpointId::new(1), "sm", "41rem"), + BreakpointDefinition::new(BreakpointId::new(0), 0, "sm", "40rem"), + BreakpointDefinition::new(BreakpointId::new(1), 1, "sm", "41rem"), ], ) .unwrap_err(); @@ -991,8 +1029,8 @@ mod tests { let duplicate_id = ThemeRegistry::from_definitions( [], [ - BreakpointDefinition::new(BreakpointId::new(0), "small", "40rem"), - BreakpointDefinition::new(BreakpointId::new(0), "wide", "80rem"), + BreakpointDefinition::new(BreakpointId::new(0), 0, "small", "40rem"), + BreakpointDefinition::new(BreakpointId::new(0), 1, "wide", "80rem"), ], ) .unwrap_err(); @@ -1004,6 +1042,23 @@ mod tests { second: "wide".into(), } ); + + let duplicate_rank = ThemeRegistry::from_definitions( + [], + [ + BreakpointDefinition::new(BreakpointId::new(0), 0, "small", "40rem"), + BreakpointDefinition::new(BreakpointId::new(1), 0, "wide", "80rem"), + ], + ) + .unwrap_err(); + assert_eq!( + duplicate_rank, + ThemeError::DuplicateBreakpointCascadeRank { + rank: 0, + first: "small".into(), + second: "wide".into(), + } + ); } #[test] @@ -1013,6 +1068,7 @@ mod tests { [], [BreakpointDefinition::new( BreakpointId::new(7), + 0, name, "50rem", )], @@ -1071,6 +1127,7 @@ mod tests { [], [BreakpointDefinition::new( BreakpointId::new(7), + 0, "print", "40rem), print", )], @@ -1092,6 +1149,7 @@ mod tests { ], [BreakpointDefinition::new( BreakpointId::new(7), + 0, "compact", ".5rem", )], @@ -1129,7 +1187,7 @@ mod tests { } #[test] - fn v2_theme_identity_stream_digest_and_id_are_frozen() { + fn v3_theme_identity_stream_digest_and_id_are_frozen() { let registry = ThemeRegistry::from_definitions( [ TokenDefinition::new(TokenKind::Color, "brand", "#36f"), @@ -1137,6 +1195,7 @@ mod tests { ], [BreakpointDefinition::new( BreakpointId::new(7), + 0, "tablet", "52rem", )], @@ -1146,38 +1205,38 @@ mod tests { let digest = Sha256::digest(&stream); let bytes = registry.to_bytes().expect("encode compatibility registry"); - assert_eq!(THEME_ID_FORMAT_VERSION, 2); - assert_eq!(THEME_BINARY_FORMAT_VERSION, 2); + assert_eq!(THEME_ID_FORMAT_VERSION, 3); + assert_eq!(THEME_BINARY_FORMAT_VERSION, 3); assert_eq!(THEME_BINARY_MAGIC, *b"PLGCTHM\0"); assert_eq!( stream, decode_hex( - "706c6965676f2d7468656d652d69640000020100000002020022aecc820000000667757474657200000006312e3572656d02019b21fbf6000000056272616e6400000004233336660300000001040007000000067461626c657400000005353272656d" + "706c6965676f2d7468656d652d69640000030100000002020022aecc820000000667757474657200000006312e3572656d02019b21fbf6000000056272616e64000000042333366603000000010400070000000000067461626c657400000005353272656d" ) ); assert_eq!( format!("{digest:x}"), - "cf5c4c0674fd1c7e5121da0d27222ae07de616ffdc3ef8f9a388bdd100946824" + "eda25b5ed8fa8ce973662d4f18a46bdce3850281287e24d934a701503b526b9c" ); assert_eq!( format!("{:032x}", registry.id().get()), - "cf5c4c0674fd1c7e5121da0d27222ae0" + "eda25b5ed8fa8ce973662d4f18a46bdc" ); assert_eq!( registry.id().to_string(), - "cf5c4c0674fd1c7e5121da0d27222ae0" + "eda25b5ed8fa8ce973662d4f18a46bdc" ); assert_eq!( bytes.len(), - 102, + 104, "theme binary length must be updated intentionally" ); assert_eq!( format!("{:x}", Sha256::digest(&bytes)), - "19ee963bb9476a70732eaf338497e977eaa6abcffaddcd35c9ebbbeeac615bc3", + "d2ce5bd919ba720e3b108fe1479e52c70b0441af708ddd5c7025e16fb2a0f4e0", "theme binary hash must be updated intentionally" ); - let decoded = ThemeRegistry::from_bytes(&bytes).expect("decode frozen v2 fixture"); + let decoded = ThemeRegistry::from_bytes(&bytes).expect("decode frozen v3 fixture"); assert_eq!(decoded.id(), registry.id()); assert_eq!(decoded.tokens(), registry.tokens()); assert_eq!(decoded.breakpoints(), registry.breakpoints()); @@ -1190,7 +1249,7 @@ mod tests { ThemeRegistry::from_bytes(&fixture), Err(ThemeBinaryError::UnsupportedVersion { found: 1, - expected: 2, + expected: 3, }) )); } diff --git a/crates/pliego-css-usage/README.md b/crates/pliego-css-usage/README.md index 5c66477..cd54a06 100644 --- a/crates/pliego-css-usage/README.md +++ b/crates/pliego-css-usage/README.md @@ -36,4 +36,4 @@ edits authored Rust, individual declarations, or theme variables. ## Stability -Published as exact-version public-preview tooling in `0.1.0-rc.2`; final `0.1.0` is not published. +Prepared as exact-version public-preview tooling for `0.1.0-rc.3`; final `0.1.0` is not published. diff --git a/crates/pliego-cssc/README.md b/crates/pliego-cssc/README.md index 5e6324a..bc49e37 100644 --- a/crates/pliego-cssc/README.md +++ b/crates/pliego-cssc/README.md @@ -7,10 +7,10 @@ CSS, compiles typed utility literals, transforms CSS, explains cascade and physical lineage, inventories migration candidates, and verifies deterministic artifacts. -Install the `0.1.0-rc.2` public-preview candidate from crates.io: +After publication, install the `0.1.0-rc.3` public-preview candidate from crates.io: ```console -cargo install pliego-cssc --version '=0.1.0-rc.2' --locked +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked ``` ## Discover commands diff --git a/crates/pliego-cssc/src/explain.rs b/crates/pliego-cssc/src/explain.rs index b2d9b64..0eea6ca 100644 --- a/crates/pliego-cssc/src/explain.rs +++ b/crates/pliego-cssc/src/explain.rs @@ -3,10 +3,10 @@ use serde::Serialize; use pliego_css_build::artifacts::{utility_domain_name, utility_form_name}; use pliego_css_cascade::{CascadeExplanation, CascadeStatus, explain_stylesheet_cascade}; use pliego_css_compiler::{ - STYLE_ID_FORMAT_VERSION, UtilityDescriptor, UtilityForm, utility_catalog, + STYLE_ID_FORMAT_VERSION, UtilityDescriptor, utility_descriptor_for_style_item, }; -use pliego_css_ir::{CLASS_NAME_FORMAT_VERSION, CandidateKind, StyleItem}; -use pliego_css_parser::{format_style_list, parse_named_candidate, parse_style_list}; +use pliego_css_ir::{CLASS_NAME_FORMAT_VERSION, StyleItem}; +use pliego_css_parser::{format_style_list, parse_style_list}; use pliego_css_theme::THEME_ID_FORMAT_VERSION; use super::{ @@ -208,7 +208,7 @@ pub(crate) fn build_explanation(arguments: &ExplainArgs) -> Result Result { - let descriptor = descriptor_for_style_item(item) + let descriptor = utility_descriptor_for_style_item(item) .ok_or_else(|| "compiler accepted a utility without catalog metadata".to_owned())?; let item_source = source .get(item.span.start..item.span.end) @@ -226,32 +226,6 @@ fn explain_style_item(source: &str, item: &StyleItem) -> Result Option<&'static UtilityDescriptor> { - match &item.candidate.kind { - CandidateKind::ArbitraryProperty { .. } => utility_catalog() - .iter() - .find(|descriptor| descriptor.form() == UtilityForm::ArbitraryProperty), - CandidateKind::Named(candidate) => { - let body = parse_named_candidate(candidate).ok()?.body; - utility_catalog() - .iter() - .find(|descriptor| { - descriptor.form() == UtilityForm::Fixed && descriptor.match_name() == body - }) - .or_else(|| { - utility_catalog() - .iter() - .filter(|descriptor| descriptor.form() == UtilityForm::Parameterized) - .filter(|descriptor| { - body.strip_prefix(descriptor.match_name()) - .is_some_and(|suffix| suffix.starts_with('-')) - }) - .max_by_key(|descriptor| descriptor.match_name().len()) - }) - } - } -} - fn print_explanation(document: &ExplainDocument) { println!("Source: {}", document.source); println!("Canonical: {}", document.canonical); diff --git a/crates/pliego-cssc/src/inspection.rs b/crates/pliego-cssc/src/inspection.rs index 25618a5..7190530 100644 --- a/crates/pliego-cssc/src/inspection.rs +++ b/crates/pliego-cssc/src/inspection.rs @@ -29,6 +29,7 @@ struct TokenInspection { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct BreakpointInspection { + cascade_rank: u16, name: String, min_width: String, } @@ -65,6 +66,7 @@ fn inspect_theme(theme: &ThemeRegistry) -> ThemeInspection { .breakpoints() .iter() .map(|breakpoint| BreakpointInspection { + cascade_rank: breakpoint.cascade_rank, name: breakpoint.name.clone(), min_width: breakpoint.min_width.clone(), }) diff --git a/crates/pliego-cssc/src/main.rs b/crates/pliego-cssc/src/main.rs index a2560f9..f85d96c 100644 --- a/crates/pliego-cssc/src/main.rs +++ b/crates/pliego-cssc/src/main.rs @@ -34,9 +34,9 @@ use pliego_css_build::artifacts::{ transform_standard_css, validate_asset_bundle_id, }; use pliego_css_compiler::{ - CssFragmentCache, STYLE_ID_FORMAT_VERSION, compose_style_override_with_theme, - emit_css_with_theme_traced, emit_theme, emit_theme_references, emit_used_theme, - lower_style_with_theme, referenced_tokens, try_encode_style_identity_with_theme, + AnalysisHost, CompileInput, CompileRequest, STYLE_ID_FORMAT_VERSION, emit_theme, + emit_theme_references, emit_used_theme, referenced_tokens, + try_encode_style_identity_with_theme, }; use pliego_css_config::{ BudgetObservation, BudgetPolicy, BudgetSubject, BudgetSubjectKind, CssBudgetInventory, @@ -47,7 +47,6 @@ use pliego_css_ir::{CLASS_NAME_FORMAT_VERSION, Diagnostic, SemanticStyle, TokenR use pliego_css_ownership::{ AssetRuleSelection as OwnershipRuleSelection, Ownership, parse_asset_plan, parse_ownership, }; -use pliego_css_parser::parse_style_list; use pliego_css_source::{ MigrationSourceKind, ScanDiagnostic, ScanReport, SourceRange, UtilityFormatFinding, expand_bundle_source_paths, expand_source_paths, format_source_paths, format_style_failure, @@ -111,7 +110,7 @@ use source_candidates::{ const POLL_INTERVAL: Duration = Duration::from_millis(100); const EVENT_FALLBACK_INTERVAL: Duration = Duration::from_secs(2); -const INSPECTION_SCHEMA_VERSION: u8 = 2; +const INSPECTION_SCHEMA_VERSION: u8 = 3; const EXPLAIN_SCHEMA_VERSION: u8 = 2; #[cfg(windows)] const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; @@ -238,7 +237,11 @@ fn command_help(command: Option<&str>) -> &'static str { } type TargetContract = CompatibilityProfile; -type CssCaches = (CssFragmentCache, FixedCssOutputCache); +#[derive(Default)] +struct CssCaches { + engine: AnalysisHost, + fixed: FixedCssOutputCache, +} #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum DiagnosticFormat { @@ -3221,6 +3224,7 @@ fn compile_bundle_group( let mut resolved_by_bundle = BTreeMap::new(); let mut emitted_themes = Vec::with_capacity(plan.bundles.len()); + let mut analysis_host = AnalysisHost::new(theme.clone()); for name in plan.bundles.keys() { let mut candidates = Vec::new(); for key in files_by_bundle @@ -3242,7 +3246,7 @@ fn compile_bundle_group( "bundle `{name}` found no PliegoCSS styles in: {sources}" ))); } - let resolved = resolve_candidates(theme, &candidates, 1)?; + let resolved = resolve_candidates(&mut analysis_host, &candidates, 1)?; if arguments.usage_report || graph.pruning.is_enabled() { usage_styles.extend(bundle_usage_inputs(name, &resolved)?); } @@ -5974,7 +5978,9 @@ fn compile_candidates_with_manifest( format: CssFormat, graph: ArtifactGraphOptions<'_>, ) -> Result { - let resolved = resolve_candidates(theme, candidates, 1)?; + let mut cache = CssCaches::default(); + cache.engine.set_theme(theme.clone()); + let resolved = resolve_candidates(&mut cache.engine, candidates, 1)?; compile_resolved_candidates_with_manifest( theme, &resolved, @@ -5982,13 +5988,13 @@ fn compile_candidates_with_manifest( targets, format, graph, - &mut CssCaches::default(), + &mut cache, ) .map_err(CliFailure::compilation) } fn resolve_candidates( - theme: &ThemeRegistry, + host: &mut AnalysisHost, candidates: &[Candidate], first_index: usize, ) -> Result, CliFailure> { @@ -5999,7 +6005,7 @@ fn resolve_candidates( let index = first_index + offset; let semantic = match candidate { Candidate::Direct { style, provenance } => { - lower_source(theme, style, index, provenance)? + lower_source(host, style, index, provenance)? } Candidate::Composition { base, @@ -6007,14 +6013,18 @@ fn resolve_candidates( provenance, } => { let base_provenance = diagnostic_segment_provenance(provenance, "base"); - let mut semantic = lower_source(theme, base, index, &base_provenance)?; + let mut semantic = lower_source(host, base, index, &base_provenance)?; for (branch_index, branch) in branches.iter().enumerate() { let branch_provenance = diagnostic_segment_provenance( provenance, &format!("branch-{}", branch_index + 1), ); - let branch = lower_source(theme, branch, index, &branch_provenance)?; - semantic = compose_style_override_with_theme(theme, semantic, branch); + let branch = lower_source(host, branch, index, &branch_provenance)?; + semantic = pliego_css_compiler::compose_style_override_with_theme( + host.theme(), + semantic, + branch, + ); } semantic } @@ -6140,12 +6150,25 @@ fn compile_resolved_candidates_with_manifest( output.push_str(&theme_css); output.push('\n'); } - let mut emission_lineage = Vec::new(); - for (stream, (semantic, _)) in &semantic_styles { - if graph.physical_trace { - let (css, lineage) = - emit_css_with_theme_traced(theme, semantic).map_err(|error| error.to_string())?; - emission_lineage.push(TraceStyle::new( + cache.engine.set_theme(theme.clone()); + let engine_result = cache + .engine + .compile(&CompileRequest { + inputs: semantic_styles + .values() + .map(|(semantic, _)| CompileInput::Semantic(semantic.clone())) + .collect(), + include_theme: false, + physical_trace: graph.physical_trace, + }) + .map_err(|error| error.to_string())?; + output.push_str(&engine_result.css); + let emission_lineage = engine_result + .styles + .into_iter() + .filter_map(|style| style.lineage) + .map(|lineage| { + TraceStyle::new( lineage.style_id.get(), lineage .rules @@ -6165,18 +6188,9 @@ fn compile_resolved_candidates_with_manifest( ) }) .collect(), - )); - output.push_str(&css); - } else { - let (css, _) = cache - .0 - .emit(stream, theme, semantic) - .map_err(|error| error.to_string())?; - output.push_str(css); - } - output.push('\n'); - } - cache.0.retain(semantic_styles.keys()); + ) + }) + .collect::>(); let (mut css, trace_input) = if graph.physical_trace { let (css, trace_input) = optimize_css_with_trace(&output, targets.lightning(), format.is_minified())?; @@ -6184,7 +6198,7 @@ fn compile_resolved_candidates_with_manifest( } else { ( cache - .1 + .fixed .optimize(&output, targets.lightning(), format.is_minified())?, None, ) @@ -6298,16 +6312,12 @@ fn collect_token_references( } fn lower_source( - theme: &ThemeRegistry, + host: &mut AnalysisHost, source: &str, index: usize, provenance: &Provenance, ) -> Result { - let syntax = parse_style_list(source).map_err(|error| { - let human = format_style_failure(index, source, &error.to_string()); - style_failure(error, human, provenance) - })?; - lower_style_with_theme(theme, &syntax).map_err(|error| { + host.analyze_literal(source).map_err(|error| { let human = format_style_failure(index, source, &error.to_string()); style_failure(error, human, provenance) }) @@ -6491,6 +6501,7 @@ fn compile_watch_snapshot( validate_path_roles(&inputs, &outputs)?; let theme = load_watch_theme_snapshot(arguments, snapshot)?; let registry = theme.registry(); + cache.css.engine.set_theme(registry.clone()); let reachability = snapshot .reachability .as_ref() @@ -6506,7 +6517,10 @@ fn compile_watch_snapshot( candidates_from_line_source(&input.path, input.utf8("watched input")?) } .map_err(|error| error.human)?; - resolved.extend(resolve_candidates(registry, &candidates, 1).map_err(|error| error.human)?); + resolved.extend( + resolve_candidates(&mut cache.css.engine, &candidates, 1) + .map_err(|error| error.human)?, + ); } let first_source_index = resolved.len() + 1; resolved.extend(cache.resolved_from_snapshot( @@ -6676,9 +6690,13 @@ impl RustScanCache { .map_err(Clone::clone)?; let candidates = candidates_from_scan_report(theme, report).map_err(|error| error.human)?; - let source_resolved = - resolve_candidates(theme, &candidates, first_index + resolved.len()) - .map_err(|error| error.human)?; + self.css.engine.set_theme(theme.clone()); + let source_resolved = resolve_candidates( + &mut self.css.engine, + &candidates, + first_index + resolved.len(), + ) + .map_err(|error| error.human)?; self.entries .get_mut(&key) .expect("active source must have a cache entry") diff --git a/crates/pliego-cssc/src/source_candidates.rs b/crates/pliego-cssc/src/source_candidates.rs index 48347c0..84bd3c3 100644 --- a/crates/pliego-cssc/src/source_candidates.rs +++ b/crates/pliego-cssc/src/source_candidates.rs @@ -1,8 +1,7 @@ use std::fs; use std::path::Path; -use pliego_css_compiler::{analyze_cross_clause_conflicts, lower_style_with_theme}; -use pliego_css_ir::SemanticStyle; +use pliego_css_compiler::{AnalysisHost, PcxError, PcxRequest}; use pliego_css_parser::{format_style_list, parse_style_list}; use pliego_css_source::{ InvocationKind, ScanFileError, ScanReport, SourceRange, StyleLiteral, expand_source_paths, @@ -140,6 +139,7 @@ pub(crate) fn candidates_from_rust( candidates_from_scan_report(theme, &report) } +#[allow(clippy::too_many_lines)] pub(crate) fn candidates_from_scan_report( theme: &ThemeRegistry, report: &ScanReport, @@ -148,6 +148,7 @@ pub(crate) fn candidates_from_scan_report( return Err(scan_failure(&report.diagnostics)); } let mut candidates = Vec::new(); + let mut host = AnalysisHost::new(theme.clone()); for invocation in &report.invocations { match &invocation.kind { InvocationKind::Pc(pc) => candidates.push(Candidate::Direct { @@ -161,66 +162,107 @@ pub(crate) fn candidates_from_scan_report( ), }), InvocationKind::Pcx(pcx) => { - let compiled_clauses = pcx - .clauses - .iter() - .map(|clause| { + let request = PcxRequest::new( + pcx.base.value.clone(), + pcx.clauses.iter().map(|clause| { clause .branches .iter() - .map(|branch| { - lower_scanned_style(theme, &branch.style, &invocation.source) - }) - .collect::, _>>() - }) - .collect::, _>>()?; - if let Err(conflict) = analyze_cross_clause_conflicts(&compiled_clauses) { - let literal = - &pcx.clauses[conflict.right_clause].branches[conflict.right_branch].style; - let slots = conflict - .slots + .map(|branch| branch.style.value.clone()) + }), + ); + let analysis = match host.analyze_pcx(&request) { + Ok(analysis) => analysis, + Err(PcxError::Base(error)) => { + return Err(scanned_style_failure( + error, + &pcx.base, + &invocation.source, + "pcx-base", + )); + } + Err(PcxError::Branch { + clause, + branch, + diagnostic, + }) => { + return Err(scanned_style_failure( + diagnostic, + &pcx.clauses[clause].branches[branch].style, + &invocation.source, + "pcx-branch", + )); + } + Err(PcxError::Conflict(conflict)) => { + let literal = &pcx.clauses[conflict.right_clause].branches + [conflict.right_branch] + .style; + let slots = conflict + .slots + .iter() + .map(|slot| format!("{slot:?}")) + .collect::>() + .join(", "); + let human = format!( + "PCX003: independent clauses {} and {} can both assign [{slots}] under the same condition; express the combined state space in one `match` at {}:{}:{} [bytes {}..{})", + conflict.left_clause + 1, + conflict.right_clause + 1, + invocation.source, + literal.range.start.line, + literal.range.start.column + 1, + literal.range.start.byte, + literal.range.end.byte, + ); + return Err(CliFailure { + human, + diagnostics: vec![CliDiagnostic { + code: "PCX003".into(), + category: "composition".into(), + severity: "error".into(), + message: format!( + "independent clauses {} and {} can both assign [{slots}] under the same condition", + conflict.left_clause + 1, + conflict.right_clause + 1, + ), + suggestion: Some( + "express the combined state space in one `match`".into(), + ), + origin: Some(CliDiagnosticOrigin { + kind: "rust".into(), + label: "pcx-cross-clause-conflict".into(), + }), + range: Some(diagnostic_range_from_source( + &invocation.source, + literal.range, + )), + style_range: None, + replacement: None, + }], + }); + } + Err(PcxError::ExpansionLimit { maximum }) => { + return Err(CliFailure::tool(format!( + "PCX004: `pcx!` expands to more than {maximum} style combinations" + ))); + } + Err(error) => return Err(CliFailure::tool(error.to_string())), + }; + for combination in &analysis.combinations { + let composition = pcx + .compositions .iter() - .map(|slot| format!("{slot:?}")) - .collect::>() - .join(", "); - let human = format!( - "PCX003: independent clauses {} and {} can both assign [{slots}] under the same condition; express the combined state space in one `match` at {}:{}:{} [bytes {}..{})", - conflict.left_clause + 1, - conflict.right_clause + 1, - invocation.source, - literal.range.start.line, - literal.range.start.column + 1, - literal.range.start.byte, - literal.range.end.byte, - ); - return Err(CliFailure { - human, - diagnostics: vec![CliDiagnostic { - code: "PCX003".into(), - category: "composition".into(), - severity: "error".into(), - message: format!( - "independent clauses {} and {} can both assign [{slots}] under the same condition", - conflict.left_clause + 1, - conflict.right_clause + 1, - ), - suggestion: Some( - "express the combined state space in one `match`".into(), - ), - origin: Some(CliDiagnosticOrigin { - kind: "rust".into(), - label: "pcx-cross-clause-conflict".into(), - }), - range: Some(diagnostic_range_from_source( - &invocation.source, - literal.range, - )), - style_range: None, - replacement: None, - }], - }); - } - for composition in &pcx.compositions { + .find(|composition| { + composition + .selections + .iter() + .map(|selection| selection.branch) + .eq(combination.selections.iter().copied()) + }) + .ok_or_else(|| { + CliFailure::tool( + "scanner and shared pcx frontend produced different combinations", + ) + })?; let reason = pcx_composition_reason(&composition.selections); let branches = composition .selections @@ -245,11 +287,12 @@ pub(crate) fn candidates_from_scan_report( Ok(candidates) } -pub(crate) fn lower_scanned_style( - theme: &ThemeRegistry, +fn scanned_style_failure( + error: pliego_css_ir::Diagnostic, literal: &StyleLiteral, file: &str, -) -> Result { + label: &str, +) -> CliFailure { let location = format!( "{file}:{}:{} [bytes {}..{})", literal.range.start.line, @@ -257,14 +300,8 @@ pub(crate) fn lower_scanned_style( literal.range.start.byte, literal.range.end.byte ); - let syntax = parse_style_list(&literal.value).map_err(|error| { - let human = format!("{error} at {location}"); - style_literal_failure(error, human, file, literal.range, "pcx-branch") - })?; - lower_style_with_theme(theme, &syntax).map_err(|error| { - let human = format!("{error} at {location}"); - style_literal_failure(error, human, file, literal.range, "pcx-branch") - }) + let human = format!("{error} at {location}"); + style_literal_failure(error, human, file, literal.range, label) } pub(crate) fn scanner_provenance( diff --git a/crates/pliego-cssc/tests/internal/unit.rs b/crates/pliego-cssc/tests/internal/unit.rs index 84c1da9..68f8793 100644 --- a/crates/pliego-cssc/tests/internal/unit.rs +++ b/crates/pliego-cssc/tests/internal/unit.rs @@ -95,7 +95,10 @@ fn json_keys(value: &serde_json::Value) -> Vec<&str> { fn assert_theme_inspection_shape(theme: &serde_json::Value) { assert_eq!(json_keys(theme), ["breakpoints", "id", "tokens"]); assert_eq!(json_keys(&theme["tokens"][0]), ["kind", "name", "value"]); - assert_eq!(json_keys(&theme["breakpoints"][0]), ["minWidth", "name"]); + assert_eq!( + json_keys(&theme["breakpoints"][0]), + ["cascadeRank", "minWidth", "name"] + ); } fn assert_manifest_style_shape(style: &serde_json::Value) { @@ -167,8 +170,8 @@ fn resolved_seed_styles(styles: &[&str]) -> Vec { provenance: cli_provenance(style, "identity-test"), }) .collect::>(); - resolve_candidates(&ThemeRegistry::seed(), &candidates, 1) - .expect("resolve identity test styles") + let mut host = AnalysisHost::default(); + resolve_candidates(&mut host, &candidates, 1).expect("resolve identity test styles") } #[test] @@ -2950,7 +2953,7 @@ fn catalog_render_is_deterministic_complete_and_uses_real_theme_css() { assert!(markdown.contains("\n## Breakpoints\n")); assert!(markdown.ends_with('\n')); - assert_eq!(parsed["schemaVersion"], 3); + assert_eq!(parsed["schemaVersion"], 4); assert_eq!(parsed["styleIdFormatVersion"], STYLE_ID_FORMAT_VERSION); assert_eq!(parsed["classNameFormatVersion"], CLASS_NAME_FORMAT_VERSION); assert_eq!(parsed["themeIdFormatVersion"], THEME_ID_FORMAT_VERSION); @@ -3397,7 +3400,7 @@ fn scanner_rejects_cross_clause_conflict_with_shared_pcx003_analysis() { } #[test] -fn manifest_schema_three_and_inspection_two_retain_ordered_deduplicated_origins() { +fn manifest_schema_three_and_inspection_three_retain_ordered_deduplicated_origins() { let artifact = compile_styles(&["flex gap-4".into(), "gap-4 flex".into()], false).expect("compile"); let reversed = @@ -3442,7 +3445,7 @@ fn manifest_schema_three_and_inspection_two_retain_ordered_deduplicated_origins( .expect("reversed inspection") ); let inspection: serde_json::Value = serde_json::from_str(&inspection).expect("inspection JSON"); - assert_eq!(inspection["schemaVersion"], 2); + assert_eq!(inspection["schemaVersion"], 3); assert_eq!(inspection["styleIdFormatVersion"], STYLE_ID_FORMAT_VERSION); assert_eq!( inspection["classNameFormatVersion"], @@ -3914,7 +3917,7 @@ fn warm_source_cache_matches_cold_compilation_across_tree_and_theme_mutations() ), (0, 1, 0, 1) ); - assert_eq!(cache.css.0.len(), 1); + assert_eq!(cache.css.engine.physical_cache_len(), 1); let mut previous = initial.snapshot; fs::write(&first_source, "fn a(){ let _ = pc!(\"grid\"); }") @@ -3945,7 +3948,7 @@ fn warm_source_cache_matches_cold_compilation_across_tree_and_theme_mutations() ), (1, 1, 1, 1) ); - assert_eq!(cache.css.0.len(), 2); + assert_eq!(cache.css.engine.physical_cache_len(), 2); previous = added.snapshot; fs::write( @@ -4026,8 +4029,8 @@ fn warm_source_cache_matches_cold_compilation_across_tree_and_theme_mutations() .collect::>(); assert!(origin_files.contains(&renamed_source.to_str().expect("UTF-8 path"))); assert!(!origin_files.contains(&second_source.to_str().expect("UTF-8 path"))); - assert_eq!(cache.css.0.len(), 2); - assert_eq!(cache.css.1.hits(), 2); + assert_eq!(cache.css.engine.physical_cache_len(), 2); + assert_eq!(cache.css.fixed.hits(), 2); previous = renamed.snapshot; fs::remove_file(&first_source).expect("remove first source"); @@ -4045,7 +4048,7 @@ fn warm_source_cache_matches_cold_compilation_across_tree_and_theme_mutations() assert!( matches!(&removed.outcome, WatchOutcome::Compiled(artifact) if !artifact.css.contains("display:grid")) ); - assert_eq!(cache.css.0.len(), 1); + assert_eq!(cache.css.engine.physical_cache_len(), 1); previous = removed.snapshot; fs::write(&invalid_source, "fn bad( {").expect("add invalid Rust source"); diff --git a/distribution/repository-distribution-v1.json b/distribution/repository-distribution-v1.json new file mode 100644 index 0000000..6eec51d --- /dev/null +++ b/distribution/repository-distribution-v1.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-repository-distribution-policy", + "repository": "https://github.com/celiumsai/pliegocss", + "releaseAssetBase": "https://github.com/celiumsai/pliegocss/releases/download", + "package": { + "name": "@pliegocss/cli", + "format": "npm-tarball", + "registryPublication": "forbidden", + "distributionChannel": "github-release-asset", + "packageManager": "pnpm", + "packageManagerVersion": "11.7.0", + "lifecycleScripts": "forbidden", + "dependencies": "forbidden" + }, + "release": { + "draftFirst": true, + "immutableRequiredBeforePublication": true, + "checksums": "sha256", + "sbom": "cyclonedx-json-1.5", + "sbomTool": "cargo-cyclonedx", + "sbomToolVersion": "0.5.9", + "provenance": "github-sigstore-attestation" + }, + "binaries": [ + "pliego-cssc", + "pliego-css-lsp" + ], + "targets": [ + { + "id": "x86_64-pc-windows-msvc", + "os": "win32", + "arch": "x64", + "runner": "windows-latest", + "extension": ".exe", + "format": "pe" + }, + { + "id": "x86_64-unknown-linux-gnu", + "os": "linux", + "arch": "x64", + "runner": "ubuntu-24.04", + "extension": "", + "format": "elf" + }, + { + "id": "aarch64-apple-darwin", + "os": "darwin", + "arch": "arm64", + "runner": "macos-15", + "extension": "", + "format": "mach-o" + } + ], + "claimBoundary": "Repository artifacts provide exact-source native CLI and LSP binaries, a no-lifecycle universal pnpm package, SHA-256 checksums, CycloneDX SBOMs, and GitHub/Sigstore provenance. They do not claim npmjs publication, Authenticode signing, Apple notarization, or support for undeclared hosts." +} diff --git a/docs/adr/0020-share-one-pure-incremental-engine.md b/docs/adr/0020-share-one-pure-incremental-engine.md new file mode 100644 index 0000000..d2935f3 --- /dev/null +++ b/docs/adr/0020-share-one-pure-incremental-engine.md @@ -0,0 +1,66 @@ +# ADR-0020: Share one pure incremental compiler engine + +- Status: Implemented; owner acceptance pending +- Date: 2026-07-22 + +## Context + +The semantic compiler was already deterministic and I/O-free, but adapters did not share one +complete execution boundary. CLI code owned physical deduplication and fragment caching, procedural +macros and source scanning duplicated `pcx` conflict/cartesian logic, and the LSP launched +`pliego-cssc` once per uncached literal or synthetic conditional document. That made editor latency, +cancellation, cache identity, and semantic parity depend on process orchestration. + +Adding a new crate would also change the exact nineteen-crate publication unit during an RC. + +## Decision + +Use `pliego-css-compiler` as the pure shared engine and add: + +- `CompileRequest → CompileResult` as the complete compile API; +- `AnalysisHost` for theme-bound semantic and physical caches; +- `PhysicalRulePlanner` as the explicit semantic-to-physical boundary; and +- `PcxRequest → PcxAnalysis` as the shared bounded conditional frontend. + +The engine performs no filesystem, environment, process, optimizer, publication, or protocol I/O. +CLI and watch retain artifact/provenance/Lightning CSS concerns. Macros retain Rust syntax and +runtime selection. LSP retains JSON-RPC, UTF-16 coordinates, document versions, and Project Index +navigation. + +`StyleId` remains semantic identity. Physical grouping is planner policy and may evolve without +changing the ID contract. + +The legacy LSP compiler-path option remains accepted during `0.1.x` but is ignored. It is not part +of cache identity and cannot cause a child process to run. + +## Rejected alternatives + +### Add `pliego-css-engine` as a twentieth crate + +Rejected for G2 because it broadens the locked publication graph without adding a necessary +ownership boundary. + +### Keep CLI subprocesses behind an LSP cache + +Rejected because process startup, temporary source mapping, stdout schemas, and PID cancellation +remain divergent execution semantics even when cached. + +### Share only helper functions + +Rejected because helpers do not establish one request/result boundary or one cache owner. + +## Consequences + +- CLI, watch, and LSP execute one compiler engine. +- Macro, scanner, and editor conditional results share one bounded algorithm. +- LSP semantic work has zero compiler child processes and zero temporary Rust files. +- Publication and protocol failure domains stay outside the compiler. +- Engine Rust APIs remain exact-version advanced surfaces until explicitly promoted for 1.0. + +## Verification + +`pnpm check:engine-boundary` freezes ownership and rejects old process/duplication markers. +Compiler and LSP unit tests cover deterministic results, cache reuse, theme invalidation, `pcx` +composition/conflicts, source ranges, and configuration identity. `pnpm integration:lsp` passes a +nonexistent legacy compiler path, compares the twenty-case CLI/LSP corpus and `PCX003`, verifies +stale-version suppression, and reports `compilerProcesses: 0`. diff --git a/docs/adr/0023-establish-benchmark-authority-v2.md b/docs/adr/0023-establish-benchmark-authority-v2.md new file mode 100644 index 0000000..94903b3 --- /dev/null +++ b/docs/adr/0023-establish-benchmark-authority-v2.md @@ -0,0 +1,75 @@ +# ADR-0023: Establish Benchmark Authority v2 + +- Status: Implemented; owner acceptance pending +- Date: 2026-07-22 + +## Context + +Gate B compared PliegoCSS with one handwritten Tailwind CSS 4.3.2 snapshot. It was useful as +historical evidence, but it could not answer whether the current competitor, the maintained v3 +line, and the frozen regression control still behaved the same way. It also retained summary +statistics instead of every paired observation and did not measure direct child-process peak +working set or Brotli payloads. + +A competitive claim that silently mixes a stale package, different corpora, unmatched process +orders, or incomplete output coverage is not an authority for a 1.0 LTS decision. + +## Decision + +Use `benchmarks/benchmark-authority-v2/oracle.json` as the machine authority for competitive +measurements. The authority defines exactly three lanes: + +- `tailwind-latest`, resolved from the current npm `latest` tag; +- `tailwind-v3-lts`, resolved from the npm `v3-lts` tag; and +- `tailwind-frozen-release`, an explicitly pinned historical regression control. + +The oracle expires after seven days. Its offline gate binds package aliases, installed versions, +lockfile integrity values, fixture materialization, methodology, and generated documentation. Its +network gate additionally proves the mutable npm tags and package integrity values are still +current. + +Measurements use micro, medium, and deterministic large source-volume corpora. Every comparison +runs five warm-up pairs and thirty measured pairs, alternates adjacent process order, keeps every +sample, verifies a fresh output against measured hashes, requires complete Tailwind candidate +coverage, and records direct child-process peak working set. Payload evidence covers raw, gzip +level 9, and Brotli quality 11 bytes for CSS and shipped HTML. + +Machine-local results can validate the harness but cannot freeze a competitive score. Immutable +evidence requires a clean source tree, canonical sample counts, a new file below +`benchmarks/evidence/v2`, and explicit security-tooling state. No score is valid if the oracle is +expired or the evidence is not immutable schema 2. + +The old Gate B document and snapshot remain historical evidence. The Gate B command now delegates +to Benchmark Authority v2 and cannot create a new handwritten baseline. + +## Rejected alternatives + +### Compare only with the latest Tailwind release + +Rejected because it loses the supported v3 adoption surface and a stable regression control. + +### Keep summary-only timing evidence + +Rejected because medians cannot prove pairing, order alternation, variance, or outliers. + +### Publish a score from a dirty worktree + +Rejected because the source state cannot be reconstructed from the commit alone. + +## Consequences + +- Competitive evidence fails closed when upstream knowledge is stale. +- All three lanes share identical corpora, reset policy, measurement process, and output checks. +- Large-corpus results measure source volume, not broader utility-catalog coverage. +- The generated authority and competitive-matrix pages derive their current state from JSON. +- A reviewed clean-tree run is still required before PliegoCSS can publish a current competitive + score. + +## Verification + +`pnpm check:benchmark-authority` validates the local oracle and generated authority page. +`pnpm check:benchmark-oracle` validates the npm registry state. `pnpm check:benchmark-smoke` +executes all nine lane/corpus comparisons. A canonical machine-local run uses +`pnpm baseline:measure-authority-v2`, followed by `pnpm check:benchmark-result -- --canonical`. +Release CI includes the live oracle gate, while fast and integration profiles protect the local +contract and executable smoke path. diff --git a/docs/adr/0024-certify-browser-output-against-tailwind.md b/docs/adr/0024-certify-browser-output-against-tailwind.md new file mode 100644 index 0000000..bfccc7f --- /dev/null +++ b/docs/adr/0024-certify-browser-output-against-tailwind.md @@ -0,0 +1,52 @@ +# ADR-0024: Certify browser output against Tailwind + +Date: 2026-07-22 + +Status: Accepted and implemented; release-candidate acceptance evidence remains source-bound + +## Context + +Compiler tests and a Chromium smoke do not prove that PliegoCSS and the current Tailwind lane +produce equivalent browser behavior. A release claim needs explicit reset ownership, computed-style +coverage, visual evidence, multiple engines, both supported CPU architectures, and artifacts bound +to one clean source identity. + +## Decision + +Browser/output certification v1 is the authority for the shared medium fixture. It compiles the +same DOM and theme with PliegoCSS and Benchmark Authority v2's `tailwind-latest` lane, then compares +52 frozen computed properties, 1/64-pixel border/text geometry, and bounded perceptual PNG output. + +Every host executes five viewport/state scenarios in two modes: `no-reset`, where neither engine +receives a reset or Tailwind Preflight, and `shared-reset`, where the same hash-bound layered reset +is prepended to both outputs. Colors normalize to 8-bit sRGB, equivalent flex-end serialization is +canonicalized, and computed lengths quantize to 0.25 CSS px. Geometry is recorded at 1/64 CSS px +and fails above 0.1 CSS px delta. Each scenario records its maximum observed geometry delta and the +host summary records the recomputed maximum; the aggregator rejects absent, invalid, over-budget, +or inconsistent values. PNG comparison uses threshold 0.2 and a maximum mismatch ratio of 0.005 +while retaining the original and diff hashes. + +The hosted matrix is the 3×3 cross-product of Chromium, Firefox, and WebKit on Windows x64, Linux +x64, and macOS ARM64. Host evidence must be clean, unexpired, tied to one commit and Git tree, and +complete before aggregation passes. The common source identity must equal the aggregator checkout; +nine mutually consistent documents from another commit are rejected. + +## Consequences + +- Local Windows runs are diagnostic even when all three engines pass. +- WebKit is evidence for the Playwright WebKit engine; it is not renamed into a Safari claim. +- The tolerance is a reviewed antialias/subpixel budget, not an exact-PNG assertion; matching box + and text-line geometry within 0.1 CSS px is independently mandatory. The 0.5% ceiling was + calibrated from a dirty local Linux WebKit container diagnostic whose glyph-edge mismatch was + 0.4545022%, with zero computed differences and a maximum measured geometry delta of 0.09375 CSS px. +- Passing covers only the frozen shared utility intent. It does not prove general Tailwind + compatibility, browser identity across engines, or a complete CSS implementation. +- `0.1.0` and later LTS promotion remain blocked until the clean hosted 9/9 artifact exists for the + release source identity. + +## Authority + +- `benchmarks/browser-output-certification-v1/authority.json` +- `scripts/browser-output-certification.mjs` +- `.github/workflows/browser-output-certification.yml` +- `scripts/check-browser-output-matrix.mjs` diff --git a/docs/adr/0025-distribute-node-launcher-from-immutable-repository-releases.md b/docs/adr/0025-distribute-node-launcher-from-immutable-repository-releases.md new file mode 100644 index 0000000..133909f --- /dev/null +++ b/docs/adr/0025-distribute-node-launcher-from-immutable-repository-releases.md @@ -0,0 +1,64 @@ +# ADR-0025: Distribute the Node launcher from immutable repository releases + +- Status: Accepted for the G5 distribution architecture +- Date: 2026-07-22 + +## Context + +PliegoCSS needs convenient CLI and LSP installation without making npmjs an authority for native +release bytes. Installing a Git dependency or mutable remote tarball directly is also too weak for +the LTS boundary: it does not prove that a reviewed release owns the exact bytes. Lifecycle download +scripts would add another executable supply-chain boundary during package installation. + +The Rust library graph remains an exact-version crates.io compatibility unit. This decision applies +only to the optional Node/native distribution. + +## Decision + +PliegoCSS will build one npm-format package named `@pliegocss/cli`, but will never publish it to +npmjs. The source manifest is `private: true`. The packed artifact has no dependencies and no +`preinstall`, `install`, `postinstall`, `prepare`, or publication lifecycle scripts. + +The package embeds `pliego-cssc` and `pliego-css-lsp` for exactly: + +- `x86_64-pc-windows-msvc`; +- `x86_64-unknown-linux-gnu`; +- `aarch64-apple-darwin`. + +Small Node launchers select the declared host and execute the adjacent native binary without a shell, +network request, mutable lookup, or environment override. Unsupported host pairs fail explicitly and +use Cargo instead. + +The canonical distribution channel is a draft-first immutable GitHub Release. Every candidate asset +set includes native archives, the universal pnpm tarball, SHA-256 checksums, CycloneDX JSON SBOMs, a +machine distribution manifest, and GitHub/Sigstore provenance attestations. A tag-triggered workflow +must fail before draft creation if repository release immutability is disabled. + +The supported consumer sequence is download → release-asset verification → attestation verification +→ local `pnpm add --save-exact`. Direct npmjs installation, a moving branch or tag, and an unverified +remote tarball are outside the support boundary. + +## Consequences + +- npmjs cannot become a second mutable release authority. +- Package installation runs no third-party or PliegoCSS lifecycle code. +- One lockable artifact works across the three declared hosts at the cost of carrying all six native + binaries. +- pnpm is the supported and recommended Node package manager; the tarball format remains interoperable + for inspection but other managers are not release-tested. +- GitHub/Sigstore provenance proves source/workflow identity. It does not claim Authenticode signing, + Apple notarization, or binary support beyond the declared matrix. +- RC.2 cannot inherit this later-source evidence. A new exact-source candidate must build, attest, + install, and publish its own immutable asset set. + +## Verification + +```console +pnpm check:repository-distribution +pnpm check:distribution-bundle -- --root= +pnpm check:distribution-install -- --tarball= +``` + +Hosted proof is owned by `.github/workflows/distribution.yml`. Release authority remains fail-closed +until the exact promotion candidate references unexpired hashed G5 artifacts and successful +attestation/install runs. diff --git a/docs/adr/0026-certify-adapter-coexistence.md b/docs/adr/0026-certify-adapter-coexistence.md new file mode 100644 index 0000000..11ad445 --- /dev/null +++ b/docs/adr/0026-certify-adapter-coexistence.md @@ -0,0 +1,60 @@ +# ADR-0026: Certify static adapter coexistence before migration promotion + +- Status: Accepted for the G6 adapter architecture +- Date: 2026-07-22 + +## Context + +Read-only Tailwind inventory and byte-exact replacement primitives do not prove that an application +can adopt PliegoCSS safely. A useful migration claim must bind the competitor version, compiled +Tailwind output, selected PliegoCSS output, template edits, browser behavior, and rollback to the +same project snapshot. One synthetic Vite fixture is insufficient for an LTS architecture. + +PliegoCSS also needs an ownership boundary. The compiler can translate supported static utility +groups into semantic styles, but it cannot truthfully execute arbitrary Tailwind configuration, +plugins, JavaScript class construction, or framework-specific template semantics. + +## Decision + +G6 uses a versioned adapter coexistence protocol with these stages: + +1. An adapter supplies a complete static HTML document and rejects every non-literal class seam. +2. The exact complete class groups are compiled by `pliego-cssc`; manifest origins are the only + authority for mapping source groups to generated `pc_` classes. +3. Tailwind v3-LTS or current v4 compiles the same document without Preflight. Its exact CSS is + inspected by `pliego-cssc audit`, including a read-only control replay. +4. The adapter creates an explicit two-file migration group: template bytes and a Pliego sidecar. + `migration-group-apply` applies only the approved after bytes. Tailwind CSS remains loaded so both + runtimes coexist during the migration window. +5. A real browser compares the pre-apply and post-apply body DOM, ARIA snapshot, frozen computed + properties, and node geometry. A Vite adapter must additionally pass a production Vite build. +6. `migration-group-rollback` must restore the exact original template, empty sidecar, and unchanged + Tailwind CSS bytes. + +The schema-1 authority contains 21 unique owned project snapshots: seven plain HTML, seven Vite, and +seven PliegoRS rendered-output cases. Ten run Tailwind v3-LTS and eleven run current v4. Hosted proof +uses Chromium on Windows x64, Linux x64, and macOS arm64. The workflow also runs the pinned real +PliegoRS SSG/resumability browser gate; rendered-output cases alone do not substitute for it. + +## Consequences + +- The protocol provides a reviewable coexistence path without claiming a general Tailwind codemod. +- Dynamic class construction, unsupported utilities, and ambiguous manifest mappings fail closed. +- Keeping Tailwind loaded makes incremental adoption possible, but removing Tailwind is a later, + explicit project decision. +- DOM comparison intentionally ignores `class` and the certification node marker; every other body + attribute and text node remains authoritative. +- PliegoRS source compatibility is pinned independently from the rendered-output corpus. +- Passing owned projects is product evidence, not external adoption evidence; external pilots belong + to G7. + +## Verification + +```console +pnpm check:adapter-coexistence-authority +pnpm check:adapter-coexistence -- --browser=chromium +pnpm integration:pliegors-browser +``` + +Hosted proof and matrix aggregation are owned by +`.github/workflows/adapter-coexistence.yml`. diff --git a/docs/adr/0027-govern-external-adoption-evidence.md b/docs/adr/0027-govern-external-adoption-evidence.md new file mode 100644 index 0000000..1ba55d2 --- /dev/null +++ b/docs/adr/0027-govern-external-adoption-evidence.md @@ -0,0 +1,47 @@ +# ADR-0027: Govern G7 with redacted, hash-bound external evidence + +- Status: Accepted for the G7 evidence architecture +- Date: 2026-07-25 + +## Context + +Owned fixtures can prove deterministic behavior but cannot prove independent adoption. G7 requires +10–15 external interviews, 20 real incidents, and 3–5 pilots across at least three independent +projects. Raw interviews and private project snapshots may contain personal or proprietary data, so +committing them would be unsafe. Counting unaudited summaries would be unverifiable. + +## Decision + +G7 uses a schema-1 external-adoption authority with three hash-bound public ledgers. Every admitted +record is pseudonymous, consented, redacted, reviewed, and bound to its private source by SHA-256. +The source remains in private custody. Owned projects, Celiums/PliegoCSS organizations, +`celiumsai` repositories, synthetic incidents, duplicate identities, incomplete pilots, and +unreviewed records fail closed. + +Recruitment uses a public, no-private-data cohort notice while consent and evidence intake happen +through private correspondence. A public issue or email is only an interest signal; it is not +consent and never counts as evidence. Interviews, incidents, and pilots all require an explicit +reviewed-redaction record. + +The 0.1.x adapter support policy is frozen alongside the authority. It certifies only complete +literal class groups in static HTML, Vite 8.1.5, and pinned PliegoRS rendered output against +Tailwind CSS 3.4.19 and 4.3.3. Tailwind remains loaded during coexistence. Dynamic classes, +arbitrary configs/plugins, Tailwind removal, and other versions are outside the claim. + +The authority validator succeeds for an honest blocked state. The promotion gate is a separate +command and fails until all external thresholds are satisfied. + +## Consequences + +- G7 progress is machine-countable without turning private research material into public source. +- The public cohort can recruit participants without becoming a shadow evidence ledger. +- A support-policy change invalidates the authority hash and requires explicit review. +- Fixtures and synthetic cases remain useful but can never inflate adoption counts. +- The repository cannot complete G7 by itself; qualified external participation is a real input. + +## Verification + +```console +pnpm check:external-adoption-authority +pnpm check:external-adoption +``` diff --git a/docs/adr/0028-consume-framework-product-topology.md b/docs/adr/0028-consume-framework-product-topology.md new file mode 100644 index 0000000..3148ea9 --- /dev/null +++ b/docs/adr/0028-consume-framework-product-topology.md @@ -0,0 +1,38 @@ +# ADR-0028: Consume framework-owned product topology + +- Status: Accepted for RC.3 source preview +- Date: 2026-08-26 + +## Context + +PliegoRS owns components, routes, islands, and Cargo source-unit registration. +PliegoCSS owns style identity, source scanning, reachability, physical bundle +planning, and artifact evidence. The original integration fixture converted +PliegoRS Rust types in application code, duplicating the adapter and tying the +proof to one dependency graph. + +## Decision + +`pliego-css-source` consumes the closed `pliegors-product-topology/1` JSON +snapshot. It validates framework-owned IDs, paths, route-to-island references, +and source units, then emits canonical reachability and a deterministic bundle +plan without linking any PliegoRS crate. + +Physical bundle IDs remain PliegoCSS-owned. Framework IDs that are not already +safe kebab-case are mapped through a domain-separated SHA-256 prefix instead of +being copied into artifact filenames. + +The snapshot does not authorize deployment, infer Cargo coverage, select an +application theme, or publish output. The initial source preview renders a seed +theme bundle plan for the executable fixture; non-seed theme, target, and +format policy must use the normal application-owned bundle-plan surface. A +complete source inventory and normal PliegoCSS artifact gates remain separate +inputs. + +## Consequences + +- PliegoRS declares product topology once. +- PliegoCSS remains optional and runtime-free. +- Both repositories can evolve in parallel against one versioned wire seam. +- Asset Plan verification and single-owner dev orchestration remain later + coordinated milestones. diff --git a/docs/adr/README.md b/docs/adr/README.md index a6dbcd1..9633c73 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,3 +24,9 @@ ADRs record decisions that constrain implementation or public behavior. - [ADR-0020: Derive PliegoRS partitions from an explicit product registry](./0020-derive-pliegors-partitions-from-product-registry.md) - [ADR-0021: Project token usage from retained semantic styles](./0021-project-token-usage-from-retained-semantic-styles.md) - [ADR-0022: Require explicit initial-render evidence for critical CSS](./0022-require-explicit-critical-style-evidence.md) +- [ADR-0023: Establish Benchmark Authority v2](./0023-establish-benchmark-authority-v2.md) +- [ADR-0024: Certify browser output against Tailwind](./0024-certify-browser-output-against-tailwind.md) +- [ADR-0025: Distribute the Node launcher from immutable repository releases](./0025-distribute-node-launcher-from-immutable-repository-releases.md) +- [ADR-0026: Certify static adapter coexistence before migration promotion](./0026-certify-adapter-coexistence.md) +- [ADR-0027: Govern G7 with redacted, hash-bound external evidence](./0027-govern-external-adoption-evidence.md) +- [ADR-0028: Consume framework-owned product topology](./0028-consume-framework-product-topology.md) diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..62c9580 --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,11 @@ +# Historical documents + +Everything below `docs/archive/` is retained for provenance only. Archived files never determine +the current release state, satisfy a current coverage requirement, or authorize publication. + +The sole machine authority for current release status is +[`../product/release-readiness-0.1.0.json`](../product/release-readiness-0.1.0.json). Its generated +Markdown projection is the only human-readable current-status page. + +Historical evidence may be cited by a current check only when schema 3 records its exact source +commit, Git tree, observation window, environment, coverage, and immutable artifact hash. diff --git a/docs/archive/evidence/hosted-browser-matrix-2026-07-19.json b/docs/archive/evidence/hosted-browser-matrix-2026-07-19.json new file mode 100644 index 0000000..e71c29c --- /dev/null +++ b/docs/archive/evidence/hosted-browser-matrix-2026-07-19.json @@ -0,0 +1,59 @@ +{ + "archiveStatus": "historical-unbound-evidence", + "supersededBy": "docs/benchmarks/hosted-browser-matrix.json", + "schemaVersion": 1, + "statusDate": "2026-07-19", + "hosts": [ + { + "id": "windows-chrome", + "os": "windows", + "browser": "chrome", + "status": "passed", + "executable": "C:/Program Files/Google/Chrome/Application/chrome.exe", + "automation": "passed", + "claim": "local-computed-style-smoke" + }, + { + "id": "windows-edge", + "os": "windows", + "browser": "edge", + "status": "passed", + "executable": "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", + "automation": "passed", + "claim": "local-computed-style-smoke" + }, + { + "id": "windows-firefox", + "os": "windows", + "browser": "firefox", + "status": "not-configured", + "executable": null, + "automation": "not-configured", + "claim": "none" + }, + { + "id": "wsl-chromium", + "os": "debian-wsl2", + "browser": "chromium", + "status": "not-configured", + "executable": null, + "automation": "not-configured", + "claim": "none" + }, + { + "id": "macos-safari", + "os": "macos", + "browser": "safari", + "status": "not-configured", + "executable": null, + "automation": "not-configured", + "claim": "none" + } + ], + "historicalEvidence": { + "chromiumComputedStyleSmoke": "docs/benchmarks/browser-validation.md", + "status": "historical-manual", + "ciReplay": "not-configured" + }, + "releaseBoundary": "Executable discovery is not behavioral certification. Only automated or recorded computed-style replay may change a host to passed." +} diff --git a/docs/product/next-gap-2026-07-20.json b/docs/archive/product/next-gap-2026-07-20.json similarity index 94% rename from docs/product/next-gap-2026-07-20.json rename to docs/archive/product/next-gap-2026-07-20.json index 9cfb79f..4faa538 100644 --- a/docs/product/next-gap-2026-07-20.json +++ b/docs/archive/product/next-gap-2026-07-20.json @@ -1,4 +1,6 @@ { + "archiveStatus": "historical-no-current-authority", + "supersededBy": "docs/product/release-readiness-0.1.0.json", "schemaVersion": 1, "statusDate": "2026-07-20", "selectedNext": "accessibility-diagnostic-quality-corpus", diff --git a/docs/product/next-gap-2026-07-20.md b/docs/archive/product/next-gap-2026-07-20.md similarity index 67% rename from docs/product/next-gap-2026-07-20.md rename to docs/archive/product/next-gap-2026-07-20.md index e5b68be..7475c28 100644 --- a/docs/product/next-gap-2026-07-20.md +++ b/docs/archive/product/next-gap-2026-07-20.md @@ -1,8 +1,12 @@ -# Next evidence-driven gap: accessibility diagnostic quality +# Archived snapshot: next evidence-driven gap -Status: **selected 2026-07-20; implementation not yet claimed** +Archive status: **historical snapshot from 2026-07-20; no current authority** -The machine authority is [`next-gap-2026-07-20.json`](./next-gap-2026-07-20.json). +The only current release-status authority is +[`../../product/release-readiness-0.1.0.json`](../../product/release-readiness-0.1.0.json). + +This snapshot's adjacent machine record is +[`next-gap-2026-07-20.json`](./next-gap-2026-07-20.json); it is historical too. Phase 13 closed the reversible migration Should with durable, bounded, cross-platform local evidence. The next locally actionable release blocker is therefore the accessibility diagnostic-quality corpus diff --git a/docs/product/hardening-report-0.1.0-rc.2.md b/docs/archive/releases/0.1.0-rc.2-hardening-report.md similarity index 94% rename from docs/product/hardening-report-0.1.0-rc.2.md rename to docs/archive/releases/0.1.0-rc.2-hardening-report.md index 33bf22f..b547839 100644 --- a/docs/product/hardening-report-0.1.0-rc.2.md +++ b/docs/archive/releases/0.1.0-rc.2-hardening-report.md @@ -1,14 +1,19 @@ -# PliegoCSS 0.1.0-rc.2 hardening report +# Archived snapshot: PliegoCSS 0.1.0-rc.2 hardening report + +Archive status: **historical release snapshot; no current promotion authority** + +The only current release-status authority is +[`../../product/release-readiness-0.1.0.json`](../../product/release-readiness-0.1.0.json). **Status:** corrections implemented; RC.2 registry replay passed; final `0.1.0` promotion remains blocked by its owner-controlled release gate **Source basis:** corrected public-preview source prepared after `aabf2b48ae53ee988d6f0ceff8276988668bbd31` -**Authority:** the machine-readable -[`release-readiness-0.1.0.json`](./release-readiness-0.1.0.json) controls the -promotion state +**Authority boundary:** this file is descriptive history; only the current +[`release-readiness-0.1.0.json`](../../product/release-readiness-0.1.0.json) +controls promotion state This report records the complete pre-release audit closure without turning a local working tree into a release claim. It separates what was reproduced on diff --git a/docs/benchmarks/adapter-coexistence-v1.md b/docs/benchmarks/adapter-coexistence-v1.md new file mode 100644 index 0000000..f28f08b --- /dev/null +++ b/docs/benchmarks/adapter-coexistence-v1.md @@ -0,0 +1,54 @@ +# Adapter coexistence certification v1 + +Status: **local Windows host passed; hosted three-host matrix is produced by the dedicated workflow** + +The schema-1 authority converts G6 from a migration proposal into a falsifiable adapter contract. +It owns 21 unique project snapshots, exact Tailwind and Vite versions, a static migration policy, +browser comparison fields, and three required Chromium hosts. + +## Coverage + +| Dimension | Contract | +|---|---| +| Projects | 21 unique rendered documents; minimum 20 | +| HTML | 7 direct-document projects | +| Vite | 7 production-build projects on Vite 8.1.5 | +| PliegoRS | 7 rendered-output projects plus one pinned real framework/browser gate | +| Tailwind | 10 v3-LTS projects and 11 current-v4 projects | +| Migration | exact two-file group, Tailwind retained, exact rollback | +| Browser | DOM, ARIA snapshot, 49 computed properties, geometry ≤ 0.1 CSS px | +| Hosted matrix | Windows x64, Linux x64, macOS arm64; Chromium | + +## Local evidence observed on 2026-07-22 + +The Windows x64 Chromium run passed all 21 projects: + +- 21/21 DOM-equivalent; +- 21/21 ARIA-equivalent; +- 21/21 computed-style-equivalent; +- 21/21 geometry-equivalent; +- 21/21 byte-exact rollbacks; +- 7/7 Vite production builds; +- 21/21 deterministic v3/v4 source inventories with zero dynamic or unsupported constructs; +- Tailwind output audit errors: zero. + +The real PliegoRS gate also passed against pinned revision +`cc61a41b49ab60eb76e82fedebccf17fe00bdc0c`. It verified two SSG routes, five selected bundles, +stylesheet preload, JS and WASM requests, island node identity, one resumed event, and the expected +state transition from 15 to 20 in Chrome 150. + +Local evidence is diagnostic and was generated from a dirty implementation tree. It does not stand +in for the clean exact-source hosted matrix. The workflow artifacts are the authority for promotion. + +## Claim boundary + +Passing proves only literal complete-class-group migration in the tracked corpus and named hosts. +Tailwind remains present during coexistence. Dynamic templates, arbitrary plugins/config execution, +general Tailwind compatibility, and external projects are excluded. + +## Verification + +```console +pnpm check:adapter-coexistence-authority +pnpm check:adapter-coexistence -- --browser=chromium +``` diff --git a/docs/benchmarks/browser-output-certification-v1.md b/docs/benchmarks/browser-output-certification-v1.md new file mode 100644 index 0000000..65f68c4 --- /dev/null +++ b/docs/benchmarks/browser-output-certification-v1.md @@ -0,0 +1,86 @@ +# Browser/output certification v1 + +Status: **implemented hosted 3×3 workflow; release evidence remains source-bound** + +This page is generated from +[`authority.json`](../../benchmarks/browser-output-certification-v1/authority.json). Edit the +JSON contract and run `node scripts/render-browser-output-certification.mjs --write`; do not +maintain the matrix by hand. + +## Scope + +The certification compiles the frozen medium fixture once with PliegoCSS and once with the fresh +`tailwind-latest` lane from Benchmark Authority v2. Within each host it compares +52 computed properties on every styled node and captures both +original screenshots plus a pixel-diff image for every scenario. + +Passing proves only the frozen shared utility intent. It is not general Tailwind compatibility, +does not compare pixels across different browser engines, and does not broaden PliegoCSS's utility +catalog. + +## Reset contract + +| Mode | Contract | +|---|---| +| `no-reset` | Neither PliegoCSS nor Tailwind receives Preflight or any external reset. | +| `shared-reset` | The exact tracked shared reset bytes are prepended to both outputs. | + +The shared reset is isolated in `@layer reset`. Tailwind Preflight is not silently enabled because +PliegoCSS does not own a Preflight implementation. This keeps the reset/no-reset comparison +explicit and prevents an unlayered reset from outranking Tailwind's layered utilities. + +## Hosted matrix + +| Host | GitHub runner | OS | Architecture | Browser engine | +|---|---|---|---|---| +| `windows-x64-chromium` | `windows-latest` | win32 | x64 | chromium | +| `windows-x64-firefox` | `windows-latest` | win32 | x64 | firefox | +| `windows-x64-webkit` | `windows-latest` | win32 | x64 | webkit | +| `linux-x64-chromium` | `ubuntu-24.04` | linux | x64 | chromium | +| `linux-x64-firefox` | `ubuntu-24.04` | linux | x64 | firefox | +| `linux-x64-webkit` | `ubuntu-24.04` | linux | x64 | webkit | +| `macos-arm64-chromium` | `macos-15` | darwin | arm64 | chromium | +| `macos-arm64-firefox` | `macos-15` | darwin | arm64 | firefox | +| `macos-arm64-webkit` | `macos-15` | darwin | arm64 | webkit | + +The matrix is the full browser/OS cross-product for Chromium, Firefox, and WebKit across Windows, +Linux, and macOS. Windows and Linux provide x64 evidence; the standard `macos-15` runner provides +ARM64 evidence. + +## Scenarios + +| Scenario | Viewport | State | +|---|---:|---| +| `base-mobile` | 375×812 | none | +| `base-tablet` | 768×1024 | none | +| `base-desktop` | 1440×900 | none | +| `hover-primary` | 1440×900 | hover | +| `focus-input` | 1440×900 | focus | + +Every scenario runs under both reset modes. Computed colors normalize to 8-bit sRGB, equivalent +flex-end keywords normalize together, and layout lengths quantize to +0.25 CSS px to account for browser-internal +subpixel serialization. The same host must also match every styled border box, client/scroll extent, +and direct text-node rectangle captured at 0.015625 CSS +px resolution with at most 0.1 CSS px delta. +Each scenario and host summary retain the maximum observed geometry delta; aggregation recomputes +that maximum and rejects missing, non-finite, negative, over-budget, or summary-drifted values. +Screenshots use pixelmatch threshold +0.2 and may differ on at most +0.5% of CSS pixels; original PNG hashes +remain retained so the tolerance cannot hide or rewrite evidence. + +## Commands + +```console +pnpm check:browser-output-authority +pnpm exec playwright install chromium firefox webkit +pnpm check:browser-output -- --browser=chromium +pnpm check:browser-output -- --browser=firefox +pnpm check:browser-output -- --browser=webkit +``` + +CI executes each of the nine hosts with `--require-clean`, uploads the JSON and PNG artifacts, +then aggregates them only when every host is unexpired, clean-tree, bound to one commit/tree, and +complete. That source identity must equal the aggregator checkout, so nine mutually consistent but +stale documents cannot pass. Local dirty-tree runs are diagnostic and cannot satisfy the hosted matrix. diff --git a/docs/benchmarks/browser-validation.md b/docs/benchmarks/browser-validation.md index 12b90b6..4054a10 100644 --- a/docs/benchmarks/browser-validation.md +++ b/docs/benchmarks/browser-validation.md @@ -2,7 +2,12 @@ Date: 2026-07-13 -Status: Chromium smoke contract passed +Status: **historical Chromium smoke; superseded for release claims by Browser/output certification v1** + +The current release contract is [Browser/output certification v1](./browser-output-certification-v1.md), +which compares PliegoCSS with the fresh Tailwind lane across Chromium, Firefox, and WebKit on the +Windows x64, Linux x64, and macOS ARM64 host matrix. This page retains the earlier smoke evidence; +it cannot satisfy the current 9/9 gate. The StyleId format-2 migration was revalidated against manifest schema 3 and the complete CSS artifact `11f632e3e4321ab93a2c8df77e7ad1ce6034f912cbec48e62b92654f01a700fe`. @@ -43,10 +48,9 @@ The full five-fixture artifact was checked at the three frozen methodology viewp - A dedicated `[data-theme="dark"]` fixture changed surface background to seed ink and foreground to white. -## Remaining browser work +## Historical boundary -This is a real computed-style smoke test, not the final compatibility matrix. Hover, reduced-motion, -contrast preferences, prefix behavior, Firefox/WebKit, visual screenshots, and automated regression -execution in CI remain open. The CLI already freezes explicit `modern` Lightning CSS targets for -Chrome/Edge 111, Firefox 128, and Safari 16.4; those configured targets still require this behavioral -certification. +This was a real computed-style smoke test, not the final compatibility matrix. The v1 certification +now implements hover/focus, responsive computed-style comparison, reset/no-reset modes, screenshots, +and CI automation. Broader reduced-motion, contrast, prefix, accessibility, and full supported-CSS +coverage remain outside that frozen fixture and need separate evidence. diff --git a/docs/benchmarks/data/pliegors-dev-loop.local.json b/docs/benchmarks/data/pliegors-dev-loop.local.json index b5fd1c3..886d4ca 100644 --- a/docs/benchmarks/data/pliegors-dev-loop.local.json +++ b/docs/benchmarks/data/pliegors-dev-loop.local.json @@ -3,321 +3,322 @@ "status": "ok", "environment": { "node": "v22.13.0", - "rust": "1.85", + "rust": "1.86.0", "platform": "linux", "architecture": "x64", - "osRelease": "6.18.33.1-microsoft-standard-WSL2", - "wsl": true, - "buildTarget": "wsl-home-cache" + "osRelease": "7.1.8-arch1-3", + "wsl": false, + "buildTarget": "default" }, "pliegocss": { - "revision": "24870f168bd5269b6e622fe20dbd50025a7f7807", + "revision": "2dbed04d39496ab076050eefaee565284ed398e2", "dirtyAtStart": false }, "pliegors": { - "revision": "baf7ad718a4964a08c940aaa423805cc7b8de830", - "sourceSha256": "a62bdf618a7d39749a0976e672de68b13a9d6e4b992594708cba408ed210ae5e" + "revision": "abb8653e75da4a5cb4dd9b51200114fbb1e760c7", + "sourceSha256": "2ba965b8d72831e3f452d91a5ba98d7297638fac661bcf1a2f7edb8ca1da68fa" }, "sampleCount": 20, "percentileMethod": "nearest-rank", "samples": [ { "probe": "p-6", - "editToCssMs": 210.689, - "editToSiteSseMs": 2008.265, + "editToCssMs": 228.719, + "editToSiteSseMs": 1546.854, "generationBefore": 0, "generationAfter": 1, "sseGeneration": 1, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 229.884, - "editToSiteSseMs": 2142.72, + "editToCssMs": 228.929, + "editToSiteSseMs": 1544.807, "generationBefore": 1, "generationAfter": 2, "sseGeneration": 2, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 267.067, - "editToSiteSseMs": 2457.537, + "editToCssMs": 229.629, + "editToSiteSseMs": 1695.053, "generationBefore": 2, "generationAfter": 3, "sseGeneration": 3, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 232.415, - "editToSiteSseMs": 2000.714, + "editToCssMs": 229.175, + "editToSiteSseMs": 1547.044, "generationBefore": 3, "generationAfter": 4, "sseGeneration": 4, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 258.481, - "editToSiteSseMs": 2149.637, + "editToCssMs": 228.825, + "editToSiteSseMs": 1695.879, "generationBefore": 4, "generationAfter": 5, "sseGeneration": 5, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 257.824, - "editToSiteSseMs": 1996.058, + "editToCssMs": 229.395, + "editToSiteSseMs": 1696.023, "generationBefore": 5, "generationAfter": 6, "sseGeneration": 6, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 266.333, - "editToSiteSseMs": 2597.553, + "editToCssMs": 228.736, + "editToSiteSseMs": 1695.921, "generationBefore": 6, "generationAfter": 7, "sseGeneration": 7, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 254.263, - "editToSiteSseMs": 2148.223, + "editToCssMs": 229.294, + "editToSiteSseMs": 1697.309, "generationBefore": 7, "generationAfter": 8, "sseGeneration": 8, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 229.209, - "editToSiteSseMs": 2143.329, + "editToCssMs": 228.341, + "editToSiteSseMs": 1546.359, "generationBefore": 8, "generationAfter": 9, "sseGeneration": 9, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 229.732, - "editToSiteSseMs": 1994.091, + "editToCssMs": 227.864, + "editToSiteSseMs": 1546.115, "generationBefore": 9, "generationAfter": 10, "sseGeneration": 10, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 283.804, - "editToSiteSseMs": 2149.222, + "editToCssMs": 229.103, + "editToSiteSseMs": 1696.979, "generationBefore": 10, "generationAfter": 11, "sseGeneration": 11, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 284.474, - "editToSiteSseMs": 1991.719, + "editToCssMs": 227.966, + "editToSiteSseMs": 1696.103, "generationBefore": 11, "generationAfter": 12, "sseGeneration": 12, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 284.945, - "editToSiteSseMs": 2299.872, + "editToCssMs": 229.013, + "editToSiteSseMs": 1695.965, "generationBefore": 12, "generationAfter": 13, "sseGeneration": 13, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 202.495, - "editToSiteSseMs": 1991.347, + "editToCssMs": 229.377, + "editToSiteSseMs": 1845.819, "generationBefore": 13, "generationAfter": 14, "sseGeneration": 14, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 232.597, - "editToSiteSseMs": 1992.659, + "editToCssMs": 228.755, + "editToSiteSseMs": 1696.852, "generationBefore": 14, "generationAfter": 15, "sseGeneration": 15, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 232.234, - "editToSiteSseMs": 2001.214, + "editToCssMs": 228.521, + "editToSiteSseMs": 1697.97, "generationBefore": 15, "generationAfter": 16, "sseGeneration": 16, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 257.576, - "editToSiteSseMs": 2143.002, + "editToCssMs": 230.823, + "editToSiteSseMs": 1697.674, "generationBefore": 16, "generationAfter": 17, "sseGeneration": 17, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 204.514, - "editToSiteSseMs": 1992.28, + "editToCssMs": 227.174, + "editToSiteSseMs": 1696.684, "generationBefore": 17, "generationAfter": 18, "sseGeneration": 18, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-6", - "editToCssMs": 201.46, - "editToSiteSseMs": 1993.671, + "editToCssMs": 228.533, + "editToSiteSseMs": 1546.274, "generationBefore": 18, "generationAfter": 19, "sseGeneration": 19, - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", "cssMatchesDisk": true, "reloadSettledMs": 2000 }, { "probe": "p-4", - "editToCssMs": 227.368, - "editToSiteSseMs": 2289.616, + "editToCssMs": 228.146, + "editToSiteSseMs": 1545.333, "generationBefore": 19, "generationAfter": 20, "sseGeneration": 20, - "className": "pc_89rkuolb9wxgvnpc398qxkvou", + "className": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", "padding": "1rem", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", "cssMatchesDisk": true, "reloadSettledMs": 2000 } ], "latencyMs": { "editToCss": { - "p50": 232.415, - "p95": 284.474, - "min": 201.46, - "max": 284.945 + "p50": 228.755, + "p95": 229.629, + "min": 227.174, + "max": 230.823 }, "editToSiteSse": { - "p50": 2008.265, - "p95": 2457.537, - "min": 1991.347, - "max": 2597.553 + "p50": 1695.921, + "p95": 1697.97, + "min": 1544.807, + "max": 1845.819 } }, "invalid": { - "generation": 20, + "generation": 21, "retainedProbe": "p-4", - "retainedClassName": "pc_89rkuolb9wxgvnpc398qxkvou", - "cssSha256": "6df8629c2fcd8ea9066e34032e6a7bac1e3c8da008c4b6c5151515957099259e", - "manifestSha256": "05499dcfd04ff050aed614b372f90d707ef0f3efa67b59f903d70f3e39b87124", + "retainedClassName": "pc_3dmp8zmu88mgsuo1wmhgaxv9a", + "cssSha256": "dfd1b9f1c15da294fe8424e71ae2a5bc89435e1924ed02874cc788c9b238228f", + "manifestSha256": "68c798141641fb7815f51684b5857d53d6bc277440df8b904b84ab20c582d051", "publicationMtimePreserved": true, + "diagnosticStatus": 500, "quietWindowMs": 2000 }, "restored": { - "generationBefore": 20, - "generationAfter": 21, - "sseGeneration": 21, + "generationBefore": 21, + "generationAfter": 22, + "sseGeneration": 22, "probe": "p-6", - "className": "pc_7ru2gfaaana56ofnpik4nuzny", + "className": "pc_4gcdt7nyls54qdjtgpszmmymj", "padding": "1.5rem", - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1" + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1" }, "equivalent": { - "generation": 21, - "cssSha256": "2d145d8ea7c7a5f0561aef83bf30fa8d855deba52b32d7f06675567d95e055e1", - "manifestSha256": "c5f94caec3e9361f58d19cba851a288320428d3d0225c49273059ca566d7735c", + "generation": 22, + "cssSha256": "0b93b1e1672bf3dfd2be03457c44e8ea3b9741b025ab2231d1e16584f73d01f1", + "manifestSha256": "18c9c6ce0004f4fc9f1a2b700ec0674b52df260a441717dd82842dcebeb3d163", "publicationMtimePreserved": true, "quietWindowMs": 2000 }, diff --git a/docs/benchmarks/hosted-browser-matrix.json b/docs/benchmarks/hosted-browser-matrix.json index 7dfd84d..eb7bfcd 100644 --- a/docs/benchmarks/hosted-browser-matrix.json +++ b/docs/benchmarks/hosted-browser-matrix.json @@ -1,57 +1,270 @@ { - "schemaVersion": 1, - "statusDate": "2026-07-19", + "schemaVersion": 2, + "kind": "pliegocss-hosted-browser-matrix", + "state": "current", + "source": { + "tag": "v0.1.0-rc.2", + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "profiles": { + "release": { + "requiredHosts": [ + "windows-x64-chromium", + "windows-x64-firefox", + "windows-x64-webkit", + "linux-x64-chromium", + "linux-x64-firefox", + "linux-x64-webkit", + "macos-arm64-chromium", + "macos-arm64-firefox", + "macos-arm64-webkit" + ], + "allowNotConfigured": false, + "allowStale": false, + "allowWaivers": false + } + }, "hosts": [ { - "id": "windows-chrome", + "id": "windows-x64-chromium", "os": "windows", - "browser": "chrome", - "status": "passed", - "executable": "C:/Program Files/Google/Chrome/Application/chrome.exe", - "automation": "passed", - "claim": "local-computed-style-smoke" + "arch": "x64", + "browser": "chromium", + "browserVersion": null, + "status": "not-configured", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, + "automation": "not-configured", + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] }, { - "id": "windows-edge", + "id": "windows-x64-firefox", "os": "windows", - "browser": "edge", - "status": "passed", - "executable": "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", - "automation": "passed", - "claim": "local-computed-style-smoke" + "arch": "x64", + "browser": "firefox", + "browserVersion": null, + "status": "not-configured", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, + "automation": "not-configured", + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] }, { - "id": "windows-firefox", + "id": "windows-x64-webkit", "os": "windows", + "arch": "x64", + "browser": "webkit", + "browserVersion": null, + "status": "not-configured", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, + "automation": "not-configured", + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] + }, + { + "id": "linux-x64-chromium", + "os": "linux", + "arch": "x64", + "browser": "chromium", + "browserVersion": null, + "status": "not-configured", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, + "automation": "not-configured", + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] + }, + { + "id": "linux-x64-firefox", + "os": "linux", + "arch": "x64", "browser": "firefox", + "browserVersion": null, + "status": "not-configured", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, + "automation": "not-configured", + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] + }, + { + "id": "linux-x64-webkit", + "os": "linux", + "arch": "x64", + "browser": "webkit", + "browserVersion": null, "status": "not-configured", - "executable": null, + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, "automation": "not-configured", - "claim": "none" + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] }, { - "id": "wsl-chromium", - "os": "debian-wsl2", + "id": "macos-arm64-chromium", + "os": "macos", + "arch": "arm64", "browser": "chromium", + "browserVersion": null, "status": "not-configured", - "executable": null, + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, "automation": "not-configured", - "claim": "none" + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] }, { - "id": "macos-safari", + "id": "macos-arm64-firefox", "os": "macos", - "browser": "safari", + "arch": "arm64", + "browser": "firefox", + "browserVersion": null, "status": "not-configured", - "executable": null, + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, "automation": "not-configured", - "claim": "none" + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] + }, + { + "id": "macos-arm64-webkit", + "os": "macos", + "arch": "arm64", + "browser": "webkit", + "browserVersion": null, + "status": "not-configured", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "observedAt": null, + "expiresAt": null, + "automation": "not-configured", + "requiredCoverage": [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest" + ], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [] } ], - "historicalEvidence": { - "chromiumComputedStyleSmoke": "docs/benchmarks/browser-validation.md", - "status": "historical-manual", - "ciReplay": "not-configured" - }, - "releaseBoundary": "Executable discovery is not behavioral certification. Only automated or recorded computed-style replay may change a host to passed." + "result": "blocked", + "missingRequiredHosts": [ + "windows-x64-chromium", + "windows-x64-firefox", + "windows-x64-webkit", + "linux-x64-chromium", + "linux-x64-firefox", + "linux-x64-webkit", + "macos-arm64-chromium", + "macos-arm64-firefox", + "macos-arm64-webkit" + ], + "releaseBoundary": "Discovery and local uncommitted browser runs are not release evidence. Browser/output certification v1 defines the required automation, but a host passes only with unexpired automated evidence bound to this exact source commit and Git tree, complete required coverage, and hashed artifacts; otherwise release remains blocked." } diff --git a/docs/benchmarks/media-query-merging.md b/docs/benchmarks/media-query-merging.md index d3bb7eb..e5e46e0 100644 --- a/docs/benchmarks/media-query-merging.md +++ b/docs/benchmarks/media-query-merging.md @@ -1,6 +1,6 @@ # Adjacent media-query merging -Date: 2026-07-13 +Date: 2026-07-22 Status: adopted for exactly equal adjacent `@media` siblings @@ -38,8 +38,8 @@ compilers. A local report records the exact Node, zlib, and compiler-binary hash | Profile | Control raw | Merged raw | Raw saved | Control gzip | Merged gzip | Gzip saved | |---|---:|---:|---:|---:|---:|---:| -| Utilities only | 1,449 B | 1,012 B | 437 B (30.159%) | 652 B | 639 B | 13 B (1.994%) | -| Seed theme + utilities | 1,820 B | 1,383 B | 437 B (24.011%) | 822 B | 809 B | 13 B (1.582%) | +| Utilities only | 1,449 B | 1,012 B | 437 B (30.159%) | 649 B | 637 B | 12 B (1.849%) | +| Seed theme + utilities | 1,820 B | 1,383 B | 437 B (24.011%) | 821 B | 809 B | 12 B (1.462%) | The adoption gate requires an exact rule sequence, 20 wrappers reduced to one, a raw reduction, and at least 8 bytes and 1% gzip reduction in the theme profile. These are deterministic size and hash @@ -52,6 +52,13 @@ both comparison fixtures. It is useful for responsive-only style runs; it is not evidence that every application becomes 1.582% smaller. +The 2026-07-22 refresh preserves the fixture bytes, exact rule-sequence assertion, and wrapper +counts while accepting StyleId-format-2 output from the shared compiler engine. Gate A is 6,548 raw +bytes / 1,596 gzip bytes with CSS SHA-256 +`1d879683af938faba7a7e44a0ad330e9f2f6a5f6b363b0f1556cc56711cd6f5b`; Gate B is 10,461 raw +bytes / 2,046 gzip bytes with CSS SHA-256 +`757e259d072723eb06ea5517627ac3422e73c16f5eb36b6ecc57c8aa104c126d`. + Run the machine check without writing a result: ```console diff --git a/docs/benchmarks/methodology.md b/docs/benchmarks/methodology.md index 3e4c81c..7c805d5 100644 --- a/docs/benchmarks/methodology.md +++ b/docs/benchmarks/methodology.md @@ -3,6 +3,36 @@ PliegoCSS comparisons freeze DOM, content, accessibility attributes, theme values, and browser targets. Only the styling authoring and compilation surface may change. +## Current competitor authority + +[Benchmark Authority v2](./tailwind-benchmark-authority-v2.md) is the only current Tailwind +comparison contract. It maintains three exact lanes: the live npm `latest`, the upstream npm +`v3-lts` tag, and one frozen release for historical regression. The oracle expires after seven days; +offline validation checks its age and package/lock integrity, while the release gate verifies the +dist-tags and SRI values against the live registry. + +Every lane runs against micro, medium, and large corpora with alternating Pliego/Tailwind process +order. Reports retain all paired samples, direct-child peak working set, raw/gzip/Brotli CSS and +HTML, separate-stream transfer, candidate coverage, deterministic hashes, and a post-sample fresh +output check. Legacy Gate A/B evidence remains historically valid but cannot supply current +competitor, memory, Brotli, or corpus-scale claims. + +## Browser/output certification + +[Browser/output certification v1](./browser-output-certification-v1.md) reuses the exact current +Tailwind lane and the frozen medium fixture, but measures behavior rather than compiler performance. +Within each host it compares 52 computed properties, 1/64-pixel box/text-line geometry bounded to +0.1 CSS px, and bounded PNG differences for three responsive viewports plus hover and focus, under +explicit no-reset and shared-reset modes. Evidence retains the maximum geometry delta per scenario; +the aggregator independently recomputes the host maximum and fails on invalid or inconsistent +values. The complete gate is +Chromium, Firefox, and WebKit on Windows x64, Linux x64, and macOS ARM64. + +Local runs may diagnose output drift but cannot become release evidence. Aggregation accepts only +all nine clean-tree host documents bound to one commit/tree, within the seven-day evidence window, +with every original/diff PNG present and hash-valid. The shared commit/tree must equal the +aggregator checkout, preventing a stale but internally consistent matrix from passing. + ## Fixtures | Fixture | Contract | @@ -116,7 +146,8 @@ violation gate. Real incidents additionally require provenance, consent/redaction status, expected diagnosis, allowed ambiguity, and stable content hashes. Synthetic conformance cases cannot be counted toward -the required 20 incidents or diagnostic precision/recall. +the required 20 incidents or diagnostic precision/recall. Admission to that count is governed by +the [G7 external adoption authority](../product/external-adoption-g7.md). ## Environment and reproducibility diff --git a/docs/benchmarks/pliego-gate-b.md b/docs/benchmarks/pliego-gate-b.md index aa376ef..6cbbfcc 100644 --- a/docs/benchmarks/pliego-gate-b.md +++ b/docs/benchmarks/pliego-gate-b.md @@ -2,7 +2,7 @@ Date: 2026-07-13 -Status: **complete fixture accepted by the compiler; production compatibility not yet proven** +Status: **historical 4.3.2 snapshot; superseded for current comparison by [Benchmark Authority v2](./tailwind-benchmark-authority-v2.md)** Gate B measures the complete tracked Tailwind fixture without removing any responsive or state variant. The harness reads `benchmarks/tailwind-v4/fixtures.html` directly, extracts its 44 `class` @@ -102,11 +102,9 @@ and stability across compiler versions remain open. See ## Reproduction -```console -pnpm baseline:measure-pliego-b -``` - -The local machine-readable result is written to `benchmarks/results/pliego-gate-b.local.json` and -remains gitignored. Frozen Tailwind figures come from the 4.3.2 complete profiles documented in -[`tailwind-v4-baseline.md`](./tailwind-v4-baseline.md). The immutable clean-commit result is -[`pliego-gate-b-2026-07-21-d16fe5d.json`](../../benchmarks/evidence/pliego-gate-b-2026-07-21-d16fe5d.json). +The original harness is preserved in the recorded source commit. The current compatibility command +`pnpm baseline:measure-pliego-b` intentionally runs Benchmark Authority v2 and writes +`benchmarks/results/benchmark-authority-v2.local.json`; it does not recreate or overwrite this +historical result. Frozen 4.3.2 figures remain in +[`pliego-gate-b-2026-07-21-d16fe5d.json`](../../benchmarks/evidence/pliego-gate-b-2026-07-21-d16fe5d.json) +and [`tailwind-v4-baseline.md`](./tailwind-v4-baseline.md). diff --git a/docs/benchmarks/pliegors-dev-loop.md b/docs/benchmarks/pliegors-dev-loop.md index 085316e..bd37b5c 100644 --- a/docs/benchmarks/pliegors-dev-loop.md +++ b/docs/benchmarks/pliegors-dev-loop.md @@ -1,7 +1,7 @@ # PliegoRS development-loop gate -Status: **automated local two-process/SSE gate green; controlled Chromium replay observed; -hosted multi-browser evidence pending** +Status: **source-pinned local two-process/SSE gate green; controlled Chromium +replay observed; hosted multi-browser evidence pending** The automated gate measures the server-side development path rather than only PliegoCSS compilation: @@ -18,9 +18,10 @@ visual/browser completion measurement. The fixture is a detached, lockfile-pinned Rust project under `integration-tests/pliegors-dev-loop`. The harness copies it to a disposable directory and pins the -sibling PliegoRS revision plus source bytes, including `crates/pliego-cli/**`, before it starts either +sibling PliegoRS revision plus source bytes, including `crates/pliego-cli/**` and the product-topology schema, before it starts either process. The canonical contract hashes committed Git blobs for `Cargo.toml`, `Cargo.lock`, the core -SSR/SSG surfaces, `crates/pliego-starters/**`, and `crates/pliego-cli/**`; covered dirty files fail +SSR/SSG surfaces, `crates/pliego-starters/**`, `crates/pliego-cli/**`, and +`schemas/pliego.product-topology.schema.json`; covered dirty files fail closed. The PliegoRS watcher excludes PliegoCSS's reserved `.pliego.lock`, `.tmp`, and `.bak` coordination files. Among PliegoCSS publication files, only a changed final CSS or manifest is application input. @@ -33,7 +34,8 @@ Run from the PliegoCSS root with a clean sibling `pliegors` checkout matching th pnpm integration:pliegors-dev ``` -The command builds both CLIs with Rust 1.85, starts `pliego-cssc watch` and `pliego dev` on an +The command currently builds both CLIs with the stricter Rust 1.86 toolchain, +starts `pliego-cssc watch` and `pliego dev` on an ephemeral loopback port, alternates `p-4` and `p-6` 20 times, and returns schema-2 JSON. On Windows machines whose Application Control policy rejects freshly rebuilt test executables, run the same script in Debian WSL2 with native Linux binaries and a target directory on the Linux filesystem: @@ -43,9 +45,9 @@ PLIEGO_DEV_TARGET_DIR="$HOME/.cache/pliegocss-dev-target" \ node scripts/check-pliegors-dev-loop.mjs ``` -The checked evidence used Node.js 22.13.0, -Rust 1.85, Debian WSL2, and a target directory on the Linux filesystem; package and source bytes -still came from the mounted checkout. +The current local replay passed on native Linux x64 with Node.js 22.13.0, +Rust 1.86, PliegoRS `abb8653e75da4a5cb4dd9b51200114fbb1e760c7`, and +20 valid edit generations plus failure/recovery/no-op checks. On a clean PliegoCSS commit, `--record-evidence` writes the same validated schema-2 report to `docs/benchmarks/data/pliegors-dev-loop.local.json`. It refuses a dirty starting tree. @@ -72,14 +74,14 @@ matches. ## Local result Twenty alternating valid edits produced in the clean recorded run for PliegoCSS -`24870f168bd5269b6e622fe20dbd50025a7f7807` and PliegoRS -`baf7ad718a4964a08c940aaa423805cc7b8de830`. The complete schema-2 artifact is +`2dbed04d39496ab076050eefaee565284ed398e2` and PliegoRS +`abb8653e75da4a5cb4dd9b51200114fbb1e760c7`. The complete schema-2 artifact is [`data/pliegors-dev-loop.local.json`](./data/pliegors-dev-loop.local.json). | Boundary | p50 | p95 | Min | Max | |---|---:|---:|---:|---:| -| Edit → changed CSS bytes | 232.415 ms | 284.474 ms | 201.460 ms | 284.945 ms | -| Edit → matching HTML/CSS and one stable SSE generation | 2,008.265 ms | 2,457.537 ms | 1,991.347 ms | 2,597.553 ms | +| Edit → changed CSS bytes | 228.755 ms | 229.629 ms | 227.174 ms | 230.823 ms | +| Edit → matching HTML/CSS and one stable SSE generation | 1,695.921 ms | 1,697.970 ms | 1,544.807 ms | 1,845.819 ms | Percentiles use nearest-rank over 20 samples. Every sample ended with the expected generated class and semantic padding, no stale class from the previous edit, exact SHA-256 equality between served @@ -95,18 +97,20 @@ PliegoRS reload client was present. ## Negative contracts -- Replacing the `pc!` value with an invalid spacing token made both compilers fail. The CSS SHA-256, - manifest SHA-256, both modification times, served class, semantic padding, page content, and reload - generation remained at the last valid `p-4` state after a 2,000 ms quiet window. +- Replacing the `pc!` value with an invalid spacing token made both compilers + fail. PliegoCSS retained CSS/manifest bytes and modification times; PliegoRS + advanced once to a bounded HTTP 500 diagnostic generation. Restoring valid + source advanced exactly once more and recovered the page. - Adding an ignored blank line to the line-oriented input caused PliegoCSS to report unchanged generated bytes. CSS and manifest hashes/modification times remained exact, and PliegoRS had not advanced its reload generation after the 2,000 ms quiet window. - The first Windows run exposed a real integration defect: PliegoRS attempted to fingerprint the held `.pliego.css.pliego.lock` file. The upstream watcher now ignores only PliegoCSS's reserved coordination namespace, with unit tests. Measurements from the defective run are discarded. -- The first 20-sample causal audit exposed a delayed duplicate PliegoRS rebuild. The watcher now - requires a stable 500 ms project snapshot and restarts that quiet window when any source or final - publication changes. The gate fails on a transient missing/stale asset or any extra generation. +- The source-pinned replay exposed access-event starvation and delayed duplicate + no-op generations. PliegoRS now filters access-only events before queueing, + drains non-access changes, and suppresses no-op publications with no changed + artifacts. ## Boundary diff --git a/docs/benchmarks/reachability-pruning.md b/docs/benchmarks/reachability-pruning.md index 7ca3d39..c0db7af 100644 --- a/docs/benchmarks/reachability-pruning.md +++ b/docs/benchmarks/reachability-pruning.md @@ -1,6 +1,6 @@ # Reachability-pruning benchmark -Date: 2026-07-17 +Date: 2026-07-22 Status: deterministic change gate for the opt-in application-union pruning contract @@ -46,28 +46,33 @@ The reviewed source SHA-256 is `159233dbb9258f93bbc0561e795780aa3b803a2cab8bfa3b19d37284d40359c0`; the generated sidecar SHA-256 is `d457dd60004d9d989d42928c5665a5ed84561bf4955f61af3a23ecf21b876a76`. +The 2026-07-22 refresh keeps those inputs and every graph count unchanged while accepting the +StyleId-format-2 output identities from the shared compiler engine. Rust 1.85/1.96 CI lanes on +Linux and macOS produced one identical result, and isolated Node 22.13/24.16 replays reproduced the +same CSS, manifest, size, and gzip hashes before this snapshot was updated. + | Profile | Styles | Semantic declarations | Token nodes | Physical rules | Physical declarations | Raw | Gzip -9 | |---|---:|---:|---:|---:|---:|---:|---:| -| Baseline schema 5 | 14 | 147 | 30 | 60 | 207 | 7,283 B | 1,214 B | +| Baseline schema 5 | 14 | 147 | 30 | 60 | 207 | 7,282 B | 1,213 B | | Pruned schema 5 | 2 | 13 | 9 | 2 | 15 | 366 B | 224 B | -| Difference | -12 | -134 | -21 | -58 | -192 | -6,917 B (94.975%) | -990 B (81.549%) | +| Difference | -12 | -134 | -21 | -58 | -192 | -6,916 B (94.974%) | -989 B (81.533%) | | Themed profile | Theme custom properties | Physical rules | Physical declarations | Raw | Gzip -9 | |---|---:|---:|---:|---:|---:| -| Baseline schema 5 | 10 | 61 | 217 | 7,654 B | 1,370 B | -| Pruned schema 5 | 3 | 3 | 18 | 457 B | 264 B | -| Difference | -7 | -58 | -199 | -7,197 B (94.029%) | -1,106 B (80.730%) | +| Baseline schema 5 | 10 | 61 | 217 | 7,653 B | 1,369 B | +| Pruned schema 5 | 3 | 3 | 18 | 457 B | 265 B | +| Difference | -7 | -58 | -199 | -7,196 B (94.028%) | -1,104 B (80.643%) | The retained variables are exactly `--color-accent`, `--color-ink`, and `--color-surface`; the seven unused seed color/font variables are absent. The themed baseline CSS SHA-256 is -`c44f73c55e746cc012b35aaa4519df2933382c4abdf00016eb117718a948033f`; the themed pruned hash is -`97f7110faa860b545b0d9a432ed68fb094088b0aebdd79db76435dbbc1c28ce5`. +`cd2a276e221155f29cfc12ff3dbd8394cd144e314ccc1a753ad097b1178cb70f`; the themed pruned hash is +`a3b4b91dad2f3e627f56d219cf476bd1dfb22b12c5669c8f26af469d380a7799`. The baseline CSS SHA-256 is -`83940c253ce21b33283b8424c88d42e41c6b2b9cab1b2193934887f93f9e9504`; the pruned CSS SHA-256 -is `fcd12e252a983989e1006701539aadf375311327ef2aa294afc1e79e44f6edb3`. Their manifest hashes -are `f4b0d70440860dfb79bbdaeff74d47ec8e71d4bf577a06d83e4165d390ddae93` and -`2a7f5766f577e487334ad014600602cb315ee846455912572e2192b2e7fa2197`, respectively. +`a0d4a94b0be27ecbd113b764eca1d20ccbf5c328c896b3f2ca1a0f86516859ee`; the pruned CSS SHA-256 +is `b1e26159f1078fd29403b5f70fb85fed7d1e290941dc07c91a476d96b317222a`. Their manifest hashes +are `a9e293c0f64cfff3658f0ca8d041c9ce659427915bb0064401999bf7ff210b5a` and +`8a991deb50fbb5299e3de3008297570bd14c62af2b47f47fe9449e344b7d241b`, respectively. Gzip byte counts are frozen across the reviewed runtimes. The harness normalizes the informational OS byte in the gzip header to `255` before hashing, then records and checks the deflate result for @@ -75,8 +80,8 @@ each reviewed zlib build: | Runtime zlib | Baseline gzip SHA-256 | Pruned gzip SHA-256 | Themed baseline | Themed pruned | |---|---|---|---|---| -| Node 22.13, `1.3.0.1-motley-82a5fec` | `f29588fd0dada2e22227f827a752785e2c4bf04f894379b41b10f42e0cc717f2` | `cf24e99f35cd7cdb95bcab9be2fc8e7508f380d8f9a20c9a2f7690ed1eda0d60` | `6fdb9eb9fdbbed3e05b6cdb75fc7c7279ee2d6c3757664c4bb5e02c3fb10bfdd` | `393437116dab5d6803a5842d899546598f5ca2f64c3990267321e76f069b27f1` | -| Node 24.16, `1.3.1-e00f703` | `f29588fd0dada2e22227f827a752785e2c4bf04f894379b41b10f42e0cc717f2` | `cf24e99f35cd7cdb95bcab9be2fc8e7508f380d8f9a20c9a2f7690ed1eda0d60` | `6fdb9eb9fdbbed3e05b6cdb75fc7c7279ee2d6c3757664c4bb5e02c3fb10bfdd` | `393437116dab5d6803a5842d899546598f5ca2f64c3990267321e76f069b27f1` | +| Node 22.13, `1.3.0.1-motley-82a5fec` | `941b57383fd67450ae34234876b2b09d64dd6fb68dd138503858b9c4dfa5b4b1` | `4b62e9053298bcfb12e07330bac4c47c68a7c45815832d4d087f89e754a4501c` | `a13dee3f2d2f5093c7b3564d17b6aad9869a05618fb887bd2c0b359dc4a57814` | `8670a47312abec7e2fe58770c2a9c136aa7529eac104923acc9c6efea4f0312c` | +| Node 24.16, `1.3.1-e00f703` | `941b57383fd67450ae34234876b2b09d64dd6fb68dd138503858b9c4dfa5b4b1` | `4b62e9053298bcfb12e07330bac4c47c68a7c45815832d4d087f89e754a4501c` | `a13dee3f2d2f5093c7b3564d17b6aad9869a05618fb887bd2c0b359dc4a57814` | `8670a47312abec7e2fe58770c2a9c136aa7529eac104923acc9c6efea4f0312c` | The gate fails if any frozen input, CSS, manifest, reviewed gzip hash, size, or graph count changes. It also fails independently if raw or gzip output stops getting smaller, even if someone updates a diff --git a/docs/benchmarks/representative-vite-tailwind.md b/docs/benchmarks/representative-vite-tailwind.md index d78d601..a6d9179 100644 --- a/docs/benchmarks/representative-vite-tailwind.md +++ b/docs/benchmarks/representative-vite-tailwind.md @@ -25,9 +25,12 @@ Every fixture file is hashed before and after to prove the command is read-only. - 46 static and zero dynamic template candidates. - Zero unresolved dependencies. -This evidence is intentionally narrower than migration success. It does not prove generated CSS -parity, semantic class conversion, reversible codemods, or visual equivalence. Those remain the gap -exposed by this application. +This gate remains intentionally narrower than migration success: its command is still read-only and +does not execute Vite or Tailwind. G6 closes a separate bounded slice through the schema-1 +[adapter coexistence certification](./adapter-coexistence-v1.md): seven Vite production builds, +literal complete-class-group conversion, Tailwind v3/v4 output audit, browser DOM/ARIA/style +equivalence, and exact rollback. Dynamic templates, arbitrary plugins/config execution, and general +codemod claims remain outside both gates. ## Reproduction diff --git a/docs/benchmarks/tailwind-benchmark-authority-v2.md b/docs/benchmarks/tailwind-benchmark-authority-v2.md new file mode 100644 index 0000000..9de5a74 --- /dev/null +++ b/docs/benchmarks/tailwind-benchmark-authority-v2.md @@ -0,0 +1,74 @@ +# Tailwind Benchmark Authority v2 + +Status: **active machine authority; generated from [`oracle.json`](../../benchmarks/benchmark-authority-v2/oracle.json)** + +This document is generated. Edit the schema-2 JSON authority and run +`node scripts/render-benchmark-authority-v2.mjs --write`; do not maintain the tables by hand. + +The primary competitor is `tailwind-latest`. The oracle was observed at +`2026-08-26T12:40:21.000Z`, expires at `2026-09-02T12:40:21.000Z`, and has a maximum age of +168 hours. An expired or live-registry-mismatched oracle blocks a new +competitive result. The reset contract is explicitly **no-preflight** because +PliegoCSS does not emit Preflight. + +## Competitor lanes + +| Lane | Role | Registry selector | Tailwind | CLI | +|---|---|---|---:|---:| +| `tailwind-latest` | current-competitor | `latest` | `4.3.3` | `4.3.3` | +| `tailwind-v3-lts` | supported-historical-major | `v3-lts` | `3.4.19` | `3.4.19` | +| `tailwind-frozen-release` | historical-regression-control | `pinned version` | `4.3.2` | `4.3.2` | + +`tailwind-frozen-release` is a regression control. It is not allowed to stand in for current +Tailwind. The v3 lane follows the upstream npm `v3-lts` dist-tag; “LTS” here names that registry +lane and does not expand Tailwind's support promises. + +## Corpora + +| Corpus | Scope | HTML bytes | Class attributes | Utility occurrences | Unique utilities | +|---|---|---:|---:|---:|---:| +| `micro` | one representative interactive component | 682 | 2 | 25 | 25 | +| `medium` | five representative views | 5,868 | 44 | 302 | 90 | +| `large` | source-volume stress corpus; utility vocabulary intentionally matches medium | 179,981 | 1,408 | 9,664 | 90 | + +The large corpus repeats the medium body 32 +times. It measures source-volume scaling while deliberately keeping the same utility vocabulary; +it is not presented as broader catalog coverage. + +## Measurement contract + +- 5 warmup pairs and 30 measured pairs per lane/corpus. +- Pair order alternates: odd pairs run PliegoCSS then Tailwind; even pairs run Tailwind then PliegoCSS. +- Every observation is a fresh process. Complete per-pair latency and peak-working-set samples are retained. +- A separate fresh invocation must reproduce each measured output hash. +- CSS and rewritten/source HTML report raw, gzip level 9, and Brotli quality 11 bytes. +- Transfer compares separate HTML and CSS streams under both compression formats. +- Candidate coverage must be 100% for the shared corpus before a pair is comparable. +- PliegoCSS and Tailwind use the same no-Preflight utility intent; browser computed-style equivalence belongs to G4. + +Peak working set is measured for the direct child process by +[`benchmark-process-metrics.rs`](../../scripts/benchmark-process-metrics.rs), not inferred from +host free-memory deltas. Process startup, security tooling and normal OS caches remain part of the +fresh-process observation; the release build happens outside the timed region. + +## Commands + +```console +pnpm check:benchmark-authority +pnpm check:benchmark-oracle +node scripts/measure-benchmark-authority-v2.mjs --smoke +node scripts/measure-benchmark-authority-v2.mjs +``` + +Immutable evidence additionally uses `--evidence=benchmarks/evidence/v2/.json`, the +canonical 5/30 sample counts, a clean source tree, a new destination, and an explicit security-tooling +declaration. Machine-local results remain ignored at +`benchmarks/results/benchmark-authority-v2.local.json`. + +## Claim boundary + +No competitive score is valid without a non-expired oracle and immutable schema-2 evidence from a clean source tree. + +Legacy Gate A/B snapshots remain historical integrity evidence. They cannot supply the current +competitor lane, memory, Brotli, or micro/medium/large claims and therefore cannot produce the v2 +competitive score. diff --git a/docs/benchmarks/tailwind-v4-baseline.md b/docs/benchmarks/tailwind-v4-baseline.md index a671679..4b8ec18 100644 --- a/docs/benchmarks/tailwind-v4-baseline.md +++ b/docs/benchmarks/tailwind-v4-baseline.md @@ -1,6 +1,6 @@ # Tailwind CSS v4 baseline -Status: exploratory F0 measurement +Status: **historical F0 measurement; superseded for current comparison by [Benchmark Authority v2](./tailwind-benchmark-authority-v2.md)** ## Frozen inputs diff --git a/docs/benchmarks/tailwind-v4-competitive-matrix.json b/docs/benchmarks/tailwind-v4-competitive-matrix.json index c68d3b0..e1dff43 100644 --- a/docs/benchmarks/tailwind-v4-competitive-matrix.json +++ b/docs/benchmarks/tailwind-v4-competitive-matrix.json @@ -1,12 +1,14 @@ { - "schemaVersion": 1, - "statusDate": "2026-07-19", + "schemaVersion": 2, + "statusDate": "2026-07-22", "baseline": { "product": "Tailwind CSS", - "version": "4.3.2", + "authority": "benchmarks/benchmark-authority-v2/oracle.json", + "primaryLane": "tailwind-latest", + "version": "4.3.3", "license": "MIT", "packageManifest": "node_modules/tailwindcss/package.json", - "packageManifestSha256": "92a1151a2e1adb85c736228008ae2d3fa293dc1c51c058f5317a238194298a97", + "packageManifestSha256": "3dc86b46c511947ca00838aa3f1776243abe923ac347c092e60a0cbc7ae68559", "fixture": "benchmarks/tailwind-v4/fixtures.html", "fixtureSha256": "338eb480327e54fe7e56e81b42c67324464afaa8c65d606e5d57c1c45690e86c", "fixtureClassAttributes": 44, @@ -57,9 +59,14 @@ "url": "https://tailwindcss.com/docs/adding-custom-styles", "versionObserved": "v4.3" }, + { + "id": "benchmark-authority-v2", + "kind": "local-contract", + "path": "docs/benchmarks/tailwind-benchmark-authority-v2.md" + }, { "id": "pliego-gate-b", - "kind": "local-evidence", + "kind": "historical-evidence", "path": "docs/benchmarks/pliego-gate-b.md" }, { @@ -76,6 +83,11 @@ "id": "pliego-maturity", "kind": "local-contract", "path": "docs/product/maturity-map.json" + }, + { + "id": "browser-output-certification-v1", + "kind": "local-contract", + "path": "docs/benchmarks/browser-output-certification-v1.md" } ], "dimensions": [ @@ -83,9 +95,9 @@ "id": "representative-fixture", "area": "utility authoring", "status": "matched", - "tailwind": "The pinned CLI generates the complete five-view fixture from visible class tokens.", - "pliego": "Gate B compiles all 44 class attributes, 302 occurrences, 90 unique concrete tokens, and all eight tracked variants.", - "evidence": ["pliego-gate-b", "pliego-utilities"], + "tailwind": "The primary 4.3.3 lane and both historical lanes must emit every candidate in the complete five-view fixture before timing is comparable.", + "pliego": "The shared medium corpus retains 44 class attributes, 302 occurrences, 90 unique concrete tokens, and all eight tracked variants.", + "evidence": ["benchmark-authority-v2", "pliego-utilities"], "releaseImpact": "keep-green", "nextAction": "Preserve the fixture as a regression vector; do not infer full catalog parity." }, @@ -144,30 +156,30 @@ "area": "base styles", "status": "tailwind-advantage", "tailwind": "Ships Preflight as part of its normal full import.", - "pliego": "Emits no reset; current benchmark comparisons correctly use Tailwind no-preflight as the primary baseline.", - "evidence": ["pliego-gate-b", "tailwind-installation"], + "pliego": "Emits no reset; Benchmark Authority v2 fixes no-preflight as the only primary comparison contract.", + "evidence": ["benchmark-authority-v2", "tailwind-installation"], "releaseImpact": "non-blocking", "nextAction": "Keep the external-reset policy explicit or add a separately versioned/measured optional reset; never fold it silently into output." }, { "id": "build-latency-fixture", "area": "performance", - "status": "matched", - "tailwind": "Pinned no-preflight complete fixture median is 204.826 ms across 30 fresh processes on the frozen host.", - "pliego": "Matched complete fixture median is 37.894 ms on the same frozen methodology, with one visible process outlier.", - "evidence": ["pliego-gate-b"], - "releaseImpact": "keep-green", - "nextAction": "Treat this as fixture evidence, not a universal speed claim; add representative-app cold/incremental/no-op and memory measurements." + "status": "open-gap", + "tailwind": "Benchmark Authority v2 pins latest, v3-lts, and a historical frozen lane but has no reviewed clean-tree v2 snapshot yet.", + "pliego": "The paired harness records complete alternating latency and peak-working-set samples across micro, medium, and large corpora; legacy Gate B numbers remain historical only.", + "evidence": ["benchmark-authority-v2", "pliego-gate-b"], + "releaseImpact": "blocking", + "nextAction": "Record and review immutable schema-2 evidence from a clean source commit before making a current speed claim." }, { "id": "payload-fixture", "area": "performance", - "status": "matched", - "tailwind": "No-preflight fixture emits 8,618 raw / 2,327 gzip CSS bytes and 3,833 gzip transfer bytes with source utility classes.", - "pliego": "Matched fixture emits 10,463 raw / 2,049 gzip CSS bytes and 3,550 gzip transfer bytes after manifest class rewriting.", - "evidence": ["pliego-gate-b"], - "releaseImpact": "keep-green", - "nextAction": "Report raw and compressed metrics together; preserve the caveat that Pliego raw CSS is larger." + "status": "open-gap", + "tailwind": "The v2 harness measures raw, gzip, and Brotli CSS plus source-utility HTML for every lane and corpus.", + "pliego": "The v2 harness measures the same compression formats after manifest-driven class rewriting and retains both separate-stream transfer totals.", + "evidence": ["benchmark-authority-v2", "pliego-gate-b"], + "releaseImpact": "blocking", + "nextAction": "Freeze a clean-tree v2 snapshot and keep all raw, gzip, Brotli, HTML, CSS, and transfer values visible." }, { "id": "rust-compile-overhead", @@ -264,7 +276,7 @@ "area": "adoption", "status": "tailwind-advantage", "tailwind": "Published npm packages and documented install paths are available today.", - "pliego": "The exact-version 0.1.0-rc.2 crate graph, CLI, and LSP are published on crates.io and replayed with Rust 1.85; editor clients remain source-distributed candidates.", + "pliego": "The exact-version 0.1.0-rc.3 crate graph, CLI, and LSP are prepared for crates.io and Rust 1.85 replay; publication, repository assets, and editor-client distribution remain candidate gates.", "evidence": ["tailwind-installation", "pliego-maturity"], "releaseImpact": "non-blocking", "nextAction": "Retain registry replay on every candidate and publish versioned editor clients before claiming installation-surface parity." @@ -274,10 +286,10 @@ "area": "platform support", "status": "open-gap", "tailwind": "Upstream documents a modern-browser compatibility floor and v4.3 behavior.", - "pliego": "Uses versioned targets and official-data policy, but complete CSS classification and hosted Chromium/Firefox/WebKit evidence remain incomplete.", - "evidence": ["tailwind-compatibility", "pliego-strategy", "pliego-maturity"], + "pliego": "Uses versioned targets and official-data policy. Browser/output certification v1 implements same-host computed, bounded 1/64-pixel geometry, and bounded PNG comparison against the current Tailwind lane across Chromium, Firefox, and WebKit; dirty local Windows x64 and Linux x64 diagnostics pass, while the clean hosted 9/9 artifact and complete CSS classification remain incomplete.", + "evidence": ["tailwind-compatibility", "pliego-strategy", "pliego-maturity", "browser-output-certification-v1"], "releaseImpact": "blocking", - "nextAction": "Close the supported CSS corpus and hosted browser matrix while preserving explicit blocked/degraded decisions." + "nextAction": "Produce the unexpired clean-tree 9/9 Windows x64, Linux x64, and macOS ARM64 matrix, then close the supported CSS corpus while preserving explicit blocked/degraded decisions." }, { "id": "responsive-diagnosis", diff --git a/docs/benchmarks/tailwind-v4-competitive-matrix.md b/docs/benchmarks/tailwind-v4-competitive-matrix.md index bf1e095..4d05694 100644 --- a/docs/benchmarks/tailwind-v4-competitive-matrix.md +++ b/docs/benchmarks/tailwind-v4-competitive-matrix.md @@ -1,115 +1,89 @@ -# PliegoCSS and Tailwind CSS v4.3.2 +# PliegoCSS and Tailwind CSS 4.3.3 -Status: **verified competitive matrix for release planning, not a claim of full feature parity** +Status: **generated competitive matrix for release planning; not a universal performance or parity claim** -This comparison pins Tailwind CSS **4.3.2** and separates representative parity, product -differentiation, Tailwind advantages, intentional design differences, and open release gaps. The -machine authority is [`tailwind-v4-competitive-matrix.json`](./tailwind-v4-competitive-matrix.json). +This document is generated from [`tailwind-v4-competitive-matrix.json`](./tailwind-v4-competitive-matrix.json). +The competitor and corpus authority is [Benchmark Authority v2](./tailwind-benchmark-authority-v2.md). +Edit those JSON contracts and regenerate; do not update this table by hand. PliegoCSS is not positioned as “Tailwind in Rust.” Tailwind is the production utility-framework -baseline; PliegoCSS is a standards-first compiler/verifier with an optional typed utility frontend. -A competitive `0.1.0` therefore needs a credible authoring path and superior verifiability, not a -line-by-line clone of Tailwind's catalog. +baseline; PliegoCSS competes through typed semantics, policy, provenance, ownership, diagnostics and +receipts. Catalog breadth and a historical single-fixture timing are not a complete score. -## Pinned evidence +## Active oracle -- Installed `tailwindcss` and `@tailwindcss/cli`: **4.3.2**, MIT. -- Complete benchmark fixture: five views, 44 class attributes, 302 utility occurrences, 90 unique - concrete utility tokens. -- Fixture SHA-256: `338eb480327e54fe7e56e81b42c67324464afaa8c65d606e5d57c1c45690e86c`. -- Upstream documentation was read from the v4.3 installation, compatibility, source-detection, - theme, states, and custom-style pages named in the machine matrix. -- Pliego evidence comes from frozen Gate A/B results and current machine-enforced contracts. +Primary lane: `tailwind-latest`. Oracle observation: `2026-08-26T12:40:21.000Z`. +Expiration: `2026-09-02T12:40:21.000Z`. Reset contract: **no-preflight**. + +| Lane | Role | Registry selector | Version | +|---|---|---|---:| +| `tailwind-latest` | current-competitor | `latest` | `4.3.3` | +| `tailwind-v3-lts` | supported-historical-major | `v3-lts` | `3.4.19` | +| `tailwind-frozen-release` | historical-regression-control | `pinned` | `4.3.2` | + +The `4.3.2` lane is historical regression control only. Current performance and payload claims +remain open until immutable schema-2 evidence is recorded from a clean tree with complete paired +samples, peak memory, gzip, Brotli and all three corpus sizes. ## Executive matrix -| Dimension | Assessment | Honest conclusion | -|---|---|---| -| Five-view utility fixture | Matched | Pliego compiles all tracked class lists and eight tracked variants. This is representative coverage, not full catalog parity. | -| Catalog breadth | Tailwind advantage | Tailwind has a much broader production utility catalog. Pliego should add only evidence-backed families. | -| Responsive/state authoring | Matched slice | `sm/md/lg`, hover/focus/focus-visible/placeholder/disabled pass the shared fixture; Pliego adds typed condition normalization. | -| Arbitrary CSS escape | Matched slice | Both preserve escape hatches; Pliego intentionally validates a narrower grammar. | -| Theme model | Different by design | Tailwind uses CSS-first `@theme`; Pliego uses typed TOML/DTCG, theme identity, and a canonical token graph while retaining CSS interoperability. | -| Source discovery | Different by design | Tailwind scans complete text tokens; Pliego prefers visible Rust literals or explicit typed adapter manifests and refuses inferred ownership. | -| Reset | Tailwind advantage | Tailwind ships Preflight; Pliego currently requires an external reset. No-preflight is the correct primary benchmark. | -| Fixture fresh-process latency | Matched/competitive | Frozen medians: Pliego 37.894 ms versus Tailwind no-preflight 204.826 ms. This is fixture evidence only. | -| Fixture payload | Matched/competitive | Pliego has larger raw CSS but smaller gzip CSS and combined transfer under the frozen fixture. All three metrics remain visible. | -| Rust cold build | Tailwind advantage | Typed macros add significant cold Cargo cost; standard-CSS audit must remain valuable without Rust migration. | -| Semantic diagnostics | Pliego differentiator | Typed domains, conflict slots, impossible conditions, and stable diagnostics fail early instead of silently dropping intent. | -| Standard-CSS governance | Pliego differentiator, open R0 work | Audit/policy/receipts exist, but complete supported classification and the generic transform/minify path are release gaps. | -| Provenance and receipts | Pliego differentiator | Versioned semantic/physical graphs and integrity-bound output/check receipts exceed the compared utility generation contract. | -| Budgets and usage evidence | Pliego differentiator | Ownership-backed budgets and conservative deadness/pruning exist; generic-CSS granularity and hosted evidence remain open. | -| Accessibility guardrails | Pliego differentiator | Explicit verified/unverified/manual/exception states exist; no WCAG certification is claimed. | -| Agent repair | Pliego differentiator, beta | Closed plans, authorization, rollback, fixed checks, and receipts exist; real-incident efficacy and hosted approvals are open. | -| Migration | Open gap | Inventory is read-only and bounded; reversible semantic codemods and visual proof are not complete. | -| Ecosystem/install | Tailwind advantage | Tailwind is published and broadly integrated; Pliego remains checkout-distributed with a strong but bounded PliegoRS path. | -| Browser support evidence | Open gap | Pliego's policy is versioned, but complete classification and hosted Chromium/Firefox/WebKit evidence block `0.1.0`. | -| Plugin model | Different by design | Pliego intentionally rejects unrestricted extension execution until a deterministic sandboxed contract exists. | - -## Frozen fixture results - -The closest payload comparison is Tailwind **no-preflight**, because PliegoCSS emits no reset. - -| Metric | Pliego complete | Tailwind no-preflight complete | -|---|---:|---:| -| Fresh-process median | 37.894 ms | 204.826 ms | -| Fresh-process minimum | 35.811 ms | 185.374 ms | -| Fresh-process p95 | 41.290 ms | 418.003 ms | -| CSS raw | 10,463 B | 8,618 B | -| CSS gzip | 2,049 B | 2,327 B | -| HTML gzip | 1,501 B | 1,506 B | -| HTML + CSS gzip | 3,550 B | 3,833 B | - -These figures do **not** prove universal speed, payload, visual parity, or production superiority. -They freeze one shared fixture, executable, methodology, and host. Tailwind full-import figures include -Preflight and are context only. - -## What “competitive 0.1.0” means - -A release is competitive when all of the following are true: - -1. The matched fixture and selected stable authoring API remain green. -2. Standard CSS receives first value without Rust or utility migration. -3. R0 compatibility, receipts, budgets, token graph, accessibility, report parity, and - determinism/portability gates close honestly. -4. Packages and installation are replayed from actual RC artifacts. -5. Pliego's differentiators remain useful in representative applications, not merely synthetic unit - fixtures. -6. Tailwind advantages are documented rather than hidden; catalog breadth is not used as the sole - definition of competitiveness. - -## Priority gaps selected by the matrix - -### Blocking `0.1.0` - -- Complete the supported standard-CSS transform/minify path and classification corpus. -- Converge the release manifest/receipt evidence without reinterpreting existing schemas. -- Close generic-CSS budget/usage identity gaps required by R0. -- Add accessibility precision/recall and browser/manual evidence. -- Obtain hosted cross-OS and Chromium/Firefox/WebKit evidence. -- Publish and replay RC artifacts after owner approval. - -### Should before release - -- Turn bounded migration inventory into at least one conservative reversible migration slice with - visual/receipt evidence, or move it through the required reviewed ADR. -- Prove one additional non-PliegoRS typed host adapter and document installation compatibility. - -### Explicitly post-0.1 - -- Full Tailwind catalog parity. -- Responsive causal diagnosis. -- Unrestricted plugins. -- Broad automatic migration or perfect-migration claims. +| Dimension | Area | Assessment | Tailwind | PliegoCSS | Release impact | +|---|---|---|---|---|---| +| `representative-fixture` | utility authoring | **matched** | The primary 4.3.3 lane and both historical lanes must emit every candidate in the complete five-view fixture before timing is comparable. | The shared medium corpus retains 44 class attributes, 302 occurrences, 90 unique concrete tokens, and all eight tracked variants. | keep-green | +| `catalog-breadth` | utility authoring | **tailwind-advantage** | Tailwind documents a broad production catalog across layout, typography, effects, filters, tables, transforms, interactivity, SVG, and accessibility. | The generated catalog is intentionally compact and covers the selected typed families plus arbitrary declarations. | non-blocking | +| `variants-responsive` | utility authoring | **matched** | Responsive and state variants are first-class authoring concepts. | The matched fixture proves sm/md/lg, hover, focus, focus-visible, placeholder, and disabled; additional typed dimensions include theme, motion, contrast, ARIA/data, direction, containers, and layers. | keep-green | +| `arbitrary-values-selectors` | escape hatches | **matched** | Arbitrary values, properties, and variants provide escape hatches within utility markup. | Parsed arbitrary values, one arbitrary declaration, custom properties, and bounded &[...] selector transforms preserve access to real CSS while rejecting unsafe/ambiguous shapes. | keep-green | +| `theme-configuration` | tokens and themes | **different-by-design** | CSS-first @theme variables define utility namespaces and can be shared as CSS. | Typed TOML and DTCG Resolver inputs produce versioned theme identity, a canonical token graph, and a Cargo build bridge. | keep-green | +| `source-detection` | content discovery | **different-by-design** | Scans source files as plain text for complete class tokens and supports explicit source registration. | Validates visible Rust macro literals or consumes explicit typed adapter manifests; it refuses to infer framework ownership from filenames/text alone. | keep-green | +| `preflight-reset` | base styles | **tailwind-advantage** | Ships Preflight as part of its normal full import. | Emits no reset; Benchmark Authority v2 fixes no-preflight as the only primary comparison contract. | non-blocking | +| `build-latency-fixture` | performance | **open-gap** | Benchmark Authority v2 pins latest, v3-lts, and a historical frozen lane but has no reviewed clean-tree v2 snapshot yet. | The paired harness records complete alternating latency and peak-working-set samples across micro, medium, and large corpora; legacy Gate B numbers remain historical only. | blocking | +| `payload-fixture` | performance | **open-gap** | The v2 harness measures raw, gzip, and Brotli CSS plus source-utility HTML for every lane and corpus. | The v2 harness measures the same compression formats after manifest-driven class rewriting and retains both separate-stream transfer totals. | blocking | +| `rust-compile-overhead` | performance | **tailwind-advantage** | Does not impose Rust procedural-macro compilation on non-Rust applications. | Typed Rust authoring adds a large cold Cargo cost in the frozen Gate A measurement, while incremental medians are much smaller. | non-blocking | +| `static-semantic-errors` | developer safety | **pliego-differentiator** | Complete-token extraction is productive but does not provide Pliego's typed property-slot conflict and theme-domain contract. | Rejects unknown utilities/tokens, invalid domains, conflicts, impossible conditions, malformed arbitrary values, and cross-branch conflicts with stable diagnostics. | keep-green | +| `standard-css-audit` | governance | **pliego-differentiator** | The compared core contract is utility generation, not a general integrity-bound CSS policy receipt. | Accepts normal CSS for bounded compatibility, budget, accessibility, finding, and receipt workflows without requiring utility migration. | blocking | +| `provenance-receipts` | governance | **pliego-differentiator** | The compared CLI emits CSS but does not expose Pliego's source/config/output decision graph and canonical build/verification receipts. | Versioned manifests, source maps, semantic/physical graphs, finding documents, and receipts integrity-bind controlled workflows. | blocking | +| `budgets-ownership-dead-css` | governance | **pliego-differentiator** | Automatic utility generation removes absent tokens, but the compared contract does not classify observed/unobserved/dead/unknown with ownership-backed route/package receipts. | Budgets, typed ownership, exact reachability/observation evidence, retention, and conservative whole-StyleId pruning are implemented. | blocking | +| `accessibility-policy` | governance | **pliego-differentiator** | Provides utilities and variants useful for accessible designs but does not certify their system-level correctness. | Static policy distinguishes verified, unverified, manual-required, violation, and exception states for five bounded guard categories. | blocking | +| `agent-repair` | agent safety | **pliego-differentiator** | The compared CLI has no equivalent closed proposal/authorization/change/verification receipt protocol. | Exact low-risk edits, budgets, external authorization, idempotence, rollback, fixed runners, and canonical verification receipts are bounded by schemas. | non-blocking | +| `migration-bridge` | adoption | **open-gap** | Has a mature installed ecosystem and direct v4 authoring path. | Can inventory bounded Tailwind/Sass/CSS Modules projects read-only, but reversible codemods and semantic migration outcomes are not implemented. | should | +| `ecosystem-frameworks` | adoption | **tailwind-advantage** | Has broad framework integrations, established documentation, and a production user ecosystem. | Has a pinned PliegoRS path, plain HTML evidence, nineteen published Rust crates, and source-distributed editor clients, but a much smaller production ecosystem. | non-blocking | +| `distribution` | adoption | **tailwind-advantage** | Published npm packages and documented install paths are available today. | The exact-version 0.1.0-rc.3 crate graph, CLI, and LSP are prepared for crates.io and Rust 1.85 replay; publication, repository assets, and editor-client distribution remain candidate gates. | non-blocking | +| `browser-compatibility` | platform support | **open-gap** | Upstream documents a modern-browser compatibility floor and v4.3 behavior. | Uses versioned targets and official-data policy. Browser/output certification v1 implements same-host computed, bounded 1/64-pixel geometry, and bounded PNG comparison against the current Tailwind lane across Chromium, Firefox, and WebKit; dirty local Windows x64 and Linux x64 diagnostics pass, while the clean hosted 9/9 artifact and complete CSS classification remain incomplete. | blocking | +| `responsive-diagnosis` | platform support | **open-gap** | Provides responsive authoring but not Pliego's proposed causal browser diagnosis product. | Current Chromium fixture checks validate known outputs; viewport-matrix failure localization is not implemented. | post-0.1 | +| `plugin-extensibility` | ecosystem | **different-by-design** | Supports custom styles, directives, and an ecosystem-oriented extension model. | Defers unrestricted plugins to protect deterministic policy, schemas, and receipts; extensions must eventually be restricted and sandboxed. | post-0.1 | + +Status totals: `matched` 3, `pliego-differentiator` 6, `tailwind-advantage` 5, `different-by-design` 3, `open-gap` 5. + +## Blocking + +- `build-latency-fixture`: Record and review immutable schema-2 evidence from a clean source commit before making a current speed claim. +- `payload-fixture`: Freeze a clean-tree v2 snapshot and keep all raw, gzip, Brotli, HTML, CSS, and transfer values visible. +- `standard-css-audit`: Close R0 standard-CSS classification and generic transform/minify output gaps before 0.1.0. +- `provenance-receipts`: Converge the R0 unified artifact and obtain hosted cross-OS evidence without relabeling existing schemas. +- `budgets-ownership-dead-css`: Close generic-CSS identities, hosted evidence, and final release replay. +- `accessibility-policy`: Add reviewed precision/recall plus browser/manual evidence; retain the explicit no-WCAG-certification limit. +- `browser-compatibility`: Produce the unexpired clean-tree 9/9 Windows x64, Linux x64, and macOS ARM64 matrix, then close the supported CSS corpus while preserving explicit blocked/degraded decisions. + +## Should + +- `migration-bridge`: Deliver conservative reversible migration slices selected from representative applications, with visual and receipt evidence. + +## Explicitly post-0.1 + +- `responsive-diagnosis`: Keep experimental until a bounded evidence schema and reviewed precision corpus exist. +- `plugin-extensibility`: Do not add unrestricted execution; design a versioned policy-pack/plugin boundary only after core release gates close. + +## Historical evidence boundary + +[Gate B](./pliego-gate-b.md) remains immutable historical evidence for Tailwind `4.3.2` on its +recorded host. It cannot represent `tailwind-latest`, v3-LTS, current paired latency, memory, +Brotli, or micro/medium/large behavior. No current competitive score is emitted from that snapshot. ## Verification ```console +pnpm check:benchmark-authority +pnpm check:benchmark-oracle pnpm check:tailwind-matrix -pnpm check:maturity -pnpm verify:fast +node scripts/measure-benchmark-authority-v2.mjs --smoke ``` - -The matrix validator binds the pinned package and fixture hashes, validates all source references and -status/release-impact enums, rejects duplicate dimensions, and freezes the status counts. It does not -fetch the network during normal verification. diff --git a/docs/concepts/css-emission.md b/docs/concepts/css-emission.md index 7396813..8771f2f 100644 --- a/docs/concepts/css-emission.md +++ b/docs/concepts/css-emission.md @@ -23,7 +23,7 @@ pc_ Equivalent normalized input therefore targets the same class. Source order and source spans are not part of semantic identity. Class-name format 1 is CSS-safe and unchanged; StyleId format 2 supplies its input through an explicit tagged binary stream and SHA-256 truncation. The current vectors are -machine-enforced prerelease contracts at `0.1.0-rc.2`, not a final `0.1.0` +machine-enforced prerelease contracts at `0.1.0-rc.3`, not a final `0.1.0` stability guarantee. Moving from the earlier format-1 candidate changes the class even though the base-36 algorithm remains format 1. diff --git a/docs/concepts/typed-ir.md b/docs/concepts/typed-ir.md index 5aafb0f..ac9a4ec 100644 --- a/docs/concepts/typed-ir.md +++ b/docs/concepts/typed-ir.md @@ -87,7 +87,7 @@ SHA-256 compacts that stream and its first 128 bits, interpreted big-endian, bec The CSS class still uses class-name format 1: `pc_` plus the complete ID in lowercase base 36. StyleId format 2 and its candidate vectors are machine-enforced at public preview -`0.1.0-rc.2`, while final `0.1.0` promotion remains open. The complete byte grammar and migration boundary are in the +`0.1.0-rc.3`, while final `0.1.0` promotion remains open. The complete byte grammar and migration boundary are in the [StyleId format-2 reference](../reference/style-id-format-v2.md). ## Persistence is not identity diff --git a/docs/contributing/external-adoption-intake.md b/docs/contributing/external-adoption-intake.md new file mode 100644 index 0000000..fc5ded4 --- /dev/null +++ b/docs/contributing/external-adoption-intake.md @@ -0,0 +1,85 @@ +# External adoption intake and evidence custody + +Status: **G7 operating protocol** + +This workflow collects external evidence without publishing personal data or proprietary project +source. It implements +[`external-adoption-v1/authority.json`](../../benchmarks/external-adoption-v1/authority.json) and +the [0.1.x adapter support policy](../reference/adapter-support-policy-0.1.x.md). + +Recruitment is open through +[GitHub issue #7](https://github.com/celiumsai/pliegocss/issues/7). Public issues are only interest +signals: do not post names, email addresses, private URLs, proprietary source, transcripts, or +incident material. Consent and evidence intake happen privately through `hello@pliegocss.dev`. + +## Before any interview or pilot + +1. Confirm that the participant controls or maintains a project outside Celiums Solutions, + PliegoCSS, and the `celiumsai` repository owner. +2. Explain the research use, the public redacted record, and private evidence custody. +3. Record affirmative consent for both research use and publication of the redacted record. +4. Assign pseudonymous participant, organization, project, and evidence IDs. Do not put names, + email addresses, access tokens, private repository URLs, or raw source in the public ledger. +5. Store the raw evidence in approved private custody and calculate its SHA-256 digest. + +The private source and the redacted record draft must remain outside the repository or under the +ignored `benchmarks/external-adoption-v1/private/` directory. Prepare a review candidate without +copying raw bytes into the output: + +```console +pnpm prepare:external-adoption-record -- \ + --type=interview \ + --source=/private/custody/interview-001.bin \ + --record=/private/custody/interview-001.public.json \ + --output=target/external-adoption-intake/interview-001.json +``` + +Consent may be withdrawn before promotion. Remove the public record, rotate the ledger hash in the +authority, and re-run the gate. A deleted or withdrawn record stops counting immediately. + +## Interview record + +One reviewed participant may satisfy one interview. Capture: + +- time, external relationship, organization pseudonym, consent and redaction review; +- stable digest and private custody reference for the source notes/transcript; +- concise findings, linked incident IDs, and linked pilot IDs; +- a second-party reviewer ID, review time, and `approved` status. + +Ten qualified participants are the minimum; fifteen is the planned sampling target. Duplicate +participant IDs fail the authority. + +## Incident record + +An incident must have occurred in an external project. Capture framework, category, severity, +provenance, expected diagnosis, allowed ambiguity, consent/redaction state, raw-evidence digest, and +review. Set `synthetic` to `false` and provenance to `external-project-observation`. + +Synthetic fixtures remain useful for conformance but never count toward the required 20 incidents. + +## Pilot record + +A qualified pilot must: + +- use static HTML, Vite 8.1.5, or the pinned PliegoRS rendered-output contract; +- use the certified Tailwind 3.4.19 or 4.3.3 profile; +- finish the bounded adoption protocol; +- pass DOM/ARIA/computed-style/layout comparison; +- prove exact rollback; +- end with zero blocking failures; +- bind an HTTPS evidence/repository reference, exact 40-character source commit, and SHA-256 digest; +- receive approved review and consent for the public redacted record. + +G7 requires three to five completed pilots, at least three unique projects, and at least three +independent organizations. + +## Publishing a reviewed record + +1. Add only the redacted record to `interviews.json`, `incidents.json`, or `pilots.json`. +2. Recompute that ledger's SHA-256 and update `authority.json`. +3. Run `pnpm check:external-adoption-authority`. +4. Run `node scripts/render-external-adoption.mjs --write`. +5. Review the diff for personal data and unsupported claims. +6. Run `pnpm check:external-adoption`. It must remain blocked until all thresholds are real. + +Raw private material belongs under the private custody system, never in the Git worktree. diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index 6effab4..4b8b4fb 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -1,14 +1,16 @@ # Release process -Status: **`0.1.0-rc.2` is the active public-preview candidate; +Status: **the workspace prepares `0.1.0-rc.3`; its exact-source candidate +identity is not established and final final `0.1.0` promotion remains blocked by the machine readiness record** This process covers the nineteen publishable crates in one version-locked compatibility unit. It does not authorize an upload. Publishing to crates.io, promoting a final release, changing repository visibility, or changing production infrastructure requires explicit owner approval at the moment of -the action. Current status is maintained in the -[machine readiness record](../product/release-readiness-0.1.0.md). +the action. Current status is maintained only in the schema-3 +[machine readiness record](../product/release-readiness-0.1.0.json); the adjacent +[Markdown page](../product/release-readiness-0.1.0.md) is generated from it. ## Publishable boundary @@ -34,13 +36,14 @@ Rust 1.85 is the library MSRV. Release packaging is a contributor operation and pinned Cargo 1.96 exactly because the package gate verifies an unpublished interdependent workspace by extracting the current archives into an isolated temporary workspace and patching every internal crates.io name to those extracted contents. Node.js 22.13 or newer runs the metadata checks; Node is -not a crate or application dependency. +not a crate dependency. The optional Node launcher is an npm-format package distributed only as a +verified GitHub Release asset and installed with pnpm; npmjs publication is forbidden. ## 1. Choose the release Before changing a version: -- decide whether the result is another candidate, such as `0.1.0-rc.2`, or the final `0.1.0`; +- decide whether the result is another candidate, such as `0.1.0-rc.3`, or the final `0.1.0`; - close or explicitly defer every item in the release-blocker section below; - move the release's user-visible changes and migrations from **Unreleased** into a dated, exact version section in `CHANGELOG.md`, leaving a new empty **Unreleased** section above it; @@ -48,11 +51,11 @@ Before changing a version: - confirm that the selected [public API candidate](../reference/public-api.md) is unchanged and decide explicitly whether this release activates it as a SemVer promise; - confirm `STYLE_ID_FORMAT_VERSION = 2`, `CLASS_NAME_FORMAT_VERSION = 1`, - `THEME_ID_FORMAT_VERSION = 2`, and `IR_BINARY_FORMAT_VERSION = 2`, plus default manifest 3, + `THEME_ID_FORMAT_VERSION = 3`, and `IR_BINARY_FORMAT_VERSION = 2`, plus default manifest 3, opt-in manifests 4/5, graphs 1/2, declaration and physical ID formats 1, reachability 1, observation/retention sidecars 1, usage analysis 1/2, Asset Plan and Project Index 1/2, ownership 1, migration inventory 1, inspect/catalog/utility-explain/cascade-explain schemas - 2/3/2/1, repair + 3/4/2/1, repair proposal/plan/dry-run/Change Receipt schemas 1.0.0, receipt result `checks-pending`, and byte-edit patch format 1, plus repair check-policy/Verification Receipt schemas 1.4.0 with canonical 1.0.0/1.1.0/1.2.0/1.3.0 read support and `passed|failed|blocked` derivation, including @@ -102,12 +105,18 @@ pnpm check:trace pnpm check:media pnpm check:pruning pnpm check:fuzz +pnpm check:benchmark-authority +pnpm check:benchmark-oracle pnpm check:evidence pnpm check:repair-corpus pnpm check:migration-corpus +pnpm check:external-adoption-authority +pnpm check:external-adoption PLIEGOCSS_RUN_NETWORK_CORPUS=1 pnpm check:migration-real-corpus pnpm check:attribution pnpm check:portability +pnpm check:release-authority +pnpm check:site-docs pnpm integration:pliegors pnpm integration:pliegors-dev pnpm integration:lsp @@ -211,6 +220,12 @@ A new immutable snapshot must also be added to the explicit filename/SHA-256 all Do not squash or rebase a recorded source commit after approval: the verifier deliberately resolves that exact historical blob. A history rewrite must supersede and regenerate the affected evidence. +`pnpm check:benchmark-authority` validates the seven-day schema-2 oracle, all three installed/locked +Tailwind lanes, deterministic corpus materialization, generated documentation, and the paired runner +contract. `pnpm check:benchmark-oracle` additionally compares npm `latest`/`v3-lts` dist-tags and +registry SRI live; release fails closed when the oracle is stale or the registry cannot be verified. +Historical Gate A/B evidence remains valid only for its recorded version and cannot satisfy this gate. + Also regenerate and check the generated catalog, benchmark evidence, and every snippet gate. A local success does not substitute for the hosted OS matrix, browser matrix, Cloudflare build, or developer onboarding. @@ -271,7 +286,18 @@ All of these must be evidenced for the exact release commit: rule; - PliegoRS integration passes from clean, revision-pinned checkouts; - documentation onboarding is completed by developers without prior project context; -- the final benchmark is reproduced and compared with the pinned Tailwind baseline. +- the final benchmark is reproduced with the live Benchmark Authority v2 oracle and reviewed + clean-tree schema-2 evidence; the frozen Tailwind lane is regression context only. + +Browser behavior is certified by `pnpm check:browser-output-authority` plus the dedicated +`browser-output-certification.yml` workflow. The workflow must produce all nine clean lanes: +Chromium, Firefox, and WebKit on Windows x64, Linux x64, and macOS ARM64. Each lane executes five +responsive/state scenarios under both `no-reset` and `shared-reset`, compares the frozen computed +property set, 1/64-pixel box/text-line geometry bounded to 0.1 CSS px, and bounded PNG output against `tailwind-latest`, +and uploads hash-bound JSON/PNG +artifacts. `pnpm check:browser-output-matrix` fails unless all nine documents are current, clean, +bound to the aggregator checkout's exact commit/tree, and complete. A stale internally consistent +matrix, local pass, or single OS cannot substitute for this gate. Name availability, repository reachability, owners, credentials, and hosted runs are time-sensitive; verify them again immediately before publication. @@ -316,8 +342,8 @@ pliego-cssc --version Create a minimal application using exact `pliego-css` and, when needed, `pliego-css-build` versions. Compile seed and custom-theme examples, run the CLI against the application source, and compare the class/manifest identities with the release evidence. Verify that default manifest schema 3 and -opt-in schemas 4/5, inspection schema 2, catalog schema 3, and utility-explain schema 2 report -StyleId/class/ThemeId formats 2/1/2. Separately verify cascade-explain schema 1 statuses, candidates, +opt-in schemas 4/5, inspection schema 3, catalog schema 4, and utility-explain schema 2 report +StyleId/class/ThemeId formats 2/1/3. Separately verify cascade-explain schema 1 statuses, candidates, blockers, and exact declaration spans. Also verify graph schemas 1/2, declaration and physical ID formats 1, exact graph-1 projection, CSS identity across manifest versions, and reachability schema 1 with the installed binary. Run `bundle --asset-plan` with manifest 4 or 5 and reachability, verify @@ -336,24 +362,62 @@ the release commit that produced the uploaded archives. After registry installat that exact unchanged commit and publish release notes from its changelog section. Do not amend the release commit after upload; any correction requires a new package version. +## 8. Build repository-hosted native and pnpm assets + +The normative policy is [`distribution/repository-distribution-v1.json`](../../distribution/repository-distribution-v1.json). +The `Repository distribution` workflow builds `pliego-cssc` and `pliego-css-lsp` for Windows x64, +Linux x64 GNU, and macOS arm64; emits target archives and CycloneDX 1.5 SBOMs; assembles one universal +`@pliegocss/cli` `.tgz`; generates `SHA256SUMS`; and installs the local tarball with pnpm on all three +hosts. The package is `private`, has zero dependencies, has no lifecycle scripts, and never fetches a +binary. + +For branch certification, run the workflow normally and retain the exact-SHA artifact. For the one +reviewed candidate that needs signed evidence, dispatch it with `attest=true`. GitHub/Sigstore +attestations then bind each binary, its SBOM, and every assembled release asset to the workflow and +source identity. This is cryptographic provenance, not Windows Authenticode signing or Apple +notarization. + +A release tag may create only a **draft** GitHub Release, and only after the repository-level +immutable-release setting is enabled. Attach every asset before publishing the draft. Publishing the +release locks its tag and assets and generates the release attestation. Verify a candidate consumer +path before publication: + +```console +gh release download --repo celiumsai/pliegocss --pattern 'pliegocss-*' +gh release verify --repo celiumsai/pliegocss +gh release verify-asset ./pliegocss-pnpm-.tgz --repo celiumsai/pliegocss +gh attestation verify ./pliegocss-pnpm-.tgz --repo celiumsai/pliegocss +pnpm add --save-dev --save-exact ./pliegocss-pnpm-.tgz +pnpm exec pliego-cssc --version +pnpm exec pliego-css-lsp --version +``` + +Never run `npm publish`, `pnpm publish`, or install the package by its npmjs name. Never promote a +workflow artifact directly: only the reviewed, complete draft Release asset set may become the +immutable public distribution. + ## Current blockers -The following remain open as of the `0.0.0` candidate: - -- the minimal public API candidate is selected and machine-checked locally, but no approved RC has - activated its SemVer promise and the final RC/version has not been chosen; -- the configured hosted CI matrix has no recorded green run because this checkout has no remote; -- browser, Cloudflare, clean-clone PliegoRS, and onboarding evidence remain incomplete; -- the strategic R0 contract is not closed: direct CLI and bundle-plan schema-2 DTCG Resolver and - canonical graph gates are green locally; Cargo build-macro DTCG selection passes the full Debian - WSL2 workspace/MSRV/API gates, the Windows dirty-package consumer gate, and the clean package gate - at commit `9714b09`. Complete attribution, accessibility policy checks, hosted cross-OS proof, and - the diagnostic-quality real-incident corpus and same-model turn-reduction evidence remain open; - the 16-case synthetic repair authority corpus is only a local conformance gate; -- the PliegoRS development stabilization revision is local-only until its `codex/` branch is reviewed - and published/merged upstream; -- the repository URL and registry ownership must be established and rechecked; -- the full documentation and final benchmark gates remain open. +The schema-3 authority still records the immutable RC.2 source and its expired +evidence. It must not be relabeled as RC.3. After this version-preparation +change becomes a clean reviewed commit, a newly named candidate can bind its +exact commit and tree and run CI, CodeQL, browser, distribution, and registry +evidence. The remaining gates are: + +- G0 corrections are present in the RC.3 source but require exact-candidate hosted replay before they + become release evidence; +- all required hosted browser lanes remain `not-configured` for the exact RC.3 candidate; +- the crates.io replay and production edge/browser replay need fresh, unexpired, artifact-hashed + evidence; +- RC.3 requires exact-source repository native archives, checksums, CycloneDX SBOMs, + GitHub/Sigstore attestations, an immutable GitHub Release, and verified pnpm package; + npmjs publication remains forbidden; +- external interviews, real incidents, and pilots are not yet recorded as reviewed adoption + evidence; +- final `0.1.0` publication is not authorized for this exact commit and tree. + +`pnpm verify:release` must therefore fail closed. A local pass, historical report, deployment URL, +or owner approval for another source identity cannot change the machine result. See the [compatibility contract](../reference/compatibility.md), [strategic product contract](../product/strategic-product-contract-2026.md), diff --git a/docs/getting-started/editor-setup.md b/docs/getting-started/editor-setup.md index b9ab70c..1a5ee2a 100644 --- a/docs/getting-started/editor-setup.md +++ b/docs/getting-started/editor-setup.md @@ -2,83 +2,66 @@ Status: native crates published; editor adapters remain local candidates -PliegoCSS uses one native language server for both supported editor clients. The editor adapter is -small and never downloads a server: install `pliego-css-lsp` and `pliego-cssc` from the same exact -checkout, then configure both executable paths explicitly. +PliegoCSS uses one native language server for both supported editor clients. Editor semantics run +inside `pliego-css-lsp`; the server does not launch `pliego-cssc`. Install the CLI separately when +the project also needs build, watch, audit, or artifact commands. -## Install matching native tools - -Install matching native tools from crates.io: +## Install the native tools ```console -cargo install pliego-cssc --version '=0.1.0-rc.2' --locked -cargo install pliego-css-lsp --version '=0.1.0-rc.2' --locked -pliego-cssc --version +cargo install pliego-css-lsp --version '=0.1.0-rc.3' --locked +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked pliego-css-lsp --version +pliego-cssc --version ``` -Both commands must report the same exact version. Do not mix binaries from -different PliegoCSS compatibility units. - -Choose one theme mode: - -- `discover` finds one conventional `pliego.theme.toml` from the workspace/package boundary; -- `seed` opts into the deterministic built-in registry; -- `config` requires one exact TOML path. - -Project Index navigation is optional. Generate `pliego.index.json` through the documented bundle -pipeline before configuring it; the server rejects stale source, manifest, Asset Plan, or CSS -bindings instead of navigating to unverified output. +Both packages belong to one exact compatibility unit. The CLI is not an LSP runtime dependency. +Choose `discover`, `seed`, or one exact `config` theme. Project Index navigation is optional and +fails closed when source, manifest, Asset Plan, or CSS bindings are stale. ## VS Code 1.105+ -The candidate extension is local and unreleased. Contributors can build and package it from the -repository root: +Build the unreleased client from the repository root: ```console pnpm --filter pliegocss-vscode check pnpm --filter pliegocss-vscode package ``` -Install the generated `target/pliegocss-vscode-0.0.0.vsix` through VS Code's **Install from VSIX** -action. Configure workspace settings with absolute executable paths: +Install `target/pliegocss-vscode-0.0.0.vsix`, then configure: ```json { "pliegocss.server.path": "/exact/path/to/pliego-css-lsp", - "pliegocss.compiler.path": "/exact/path/to/pliego-cssc", "pliegocss.theme.mode": "seed", "pliegocss.projectIndex.path": "target/site/assets/pliego.index.json" } ``` -For `config` mode, also set `pliegocss.theme.config`. Run **PliegoCSS: Restart Language Server** -after replacing a native binary, changing theme selection, or regenerating an external Project -Index. Only the first local workspace folder is supported; virtual and untrusted workspaces fail -closed. +`pliegocss.compiler.path` is a deprecated 0.1 compatibility setting. Existing configurations may +retain it, but the server ignores the forwarded path. For `config` mode also set +`pliegocss.theme.config`. Restart the language server after changing theme selection or replacing a +Project Index. ## Neovim 0.12+ -Add `editors/neovim` from the exact checkout to `runtimepath`, then configure the built-in LSP -client: +Add `editors/neovim` from the exact checkout to `runtimepath`: ```lua require("pliegocss").setup({ server = "/exact/path/to/pliego-css-lsp", - compiler = "/exact/path/to/pliego-cssc", root_dir = vim.fn.getcwd(), theme = { mode = "seed" }, project_index = "target/site/assets/pliego.index.json", }) ``` -For a custom theme use `theme = { mode = "config", path = "/exact/path/pliego.theme.toml" }`. -Omit `project_index` when physical Go to Definition is not generated. The module starts only for -file-backed Rust buffers and validates every configured file before launching the client. +The optional legacy `compiler` key is accepted by the current client but no longer supplies editor +semantics. For a custom theme use `theme = { mode = "config", path = "/exact/path/pliego.theme.toml" }`. ## Verify the connection -Open a saved Rust file containing a visible utility literal, for example: +Open a saved Rust file containing: ```rust,no_run use pliego_css::pc; @@ -90,30 +73,20 @@ fn card_class() -> String { Expected behavior: -1. completion inside the literal is catalog-backed; -2. hover shows compiler-emitted CSS; -3. changing `gap-4` to `unknown-thing` publishes `PCS001` at the exact utility range; -4. formatting returns a version-bound literal edit without writing the file itself; -5. Go to Definition is advertised only when a Project Index path was configured. +1. completion comes from the compiler catalog; +2. hover includes compiler-emitted CSS; +3. `unknown-thing` publishes `PCS001` at the exact utility range; +4. formatting returns a version-bound literal edit without writing the file; +5. Go to Definition appears only with a verified Project Index. -Repository gates use pinned real hosts: +Repository gates: ```console +pnpm integration:lsp pnpm integration:vscode-host pnpm integration:neovim -pnpm integration:lsp ``` -The first two commands may download their pinned editor test runtimes. That download belongs to the -test harness; neither client downloads PliegoCSS executables in normal operation. - -## Current boundary - -The server supports full-document synchronization, not incremental text edits. Semantic checks are -debounced for 150 ms, stale direct compiler children are cancelled per URI/version, and current -typed diagnostic families are checked against CLI schema 1 through corpus schema 2. Hosted editor -evidence, multi-root workspaces, code actions, semantic tokens, signed packages, and Marketplace or -plugin-registry publication remain release work. - -See [LSP troubleshooting](../troubleshooting/lsp.md) for startup, theme, diagnostics, and navigation -failures. +The server supports full-document synchronization and a single local root. Hosted editor evidence, +multi-root workspaces, code actions, semantic tokens, signed packages, and registry publication +remain release work. See [LSP troubleshooting](../troubleshooting/lsp.md). diff --git a/docs/getting-started/first-compile.md b/docs/getting-started/first-compile.md index 0d9b3c3..281fe63 100644 --- a/docs/getting-started/first-compile.md +++ b/docs/getting-started/first-compile.md @@ -1,12 +1,12 @@ # First compile -Status: available in the `0.1.0-rc.2` public preview +Status: prepared for the `0.1.0-rc.3` public-preview candidate Add the exact public-preview compatibility unit: ```toml [dependencies] -pliego-css = "=0.1.0-rc.2" +pliego-css = "=0.1.0-rc.3" ``` Then validate a utility list during Rust compilation: @@ -110,7 +110,7 @@ downstream tests freeze macro/CLI identity for the same Resolver selection. The CLI defaults to the `modern` target contract (Chrome 111, Edge 111, Firefox 128, Safari 16.4) and writes a schema-3 manifest when requested. The manifest reports StyleId format 2, class-name -format 1, and ThemeId format 2 as required top-level fields. It auto-discovers one +format 1, and ThemeId format 3 as required top-level fields. It auto-discovers one `pliego.theme.toml` from the working directory or supplied source package; select an exact file with `--config`, prevent discovery with `--seed`, or explicitly select a Resolver document with `--tokens`. Only TOML participates in conventional discovery. Source-directory scanning is diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 94f6f57..fbbf219 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,8 +1,8 @@ # Installation -PliegoCSS `0.1.0-rc.2` is a public-preview, exact-version compatibility unit. -The nineteen crates are published together and must not be mixed across -PliegoCSS versions. +PliegoCSS `0.1.0-rc.3` is the next public-preview, exact-version compatibility +unit. The nineteen crates must be published together and must not be mixed +across PliegoCSS versions. ## Requirements @@ -13,26 +13,52 @@ PliegoCSS versions. The PliegoCSS repository pins Rust 1.96 with `rust-toolchain.toml` for contributor tooling. That pin does not raise the crate MSRV above Rust 1.85. -Node.js 22.13 or newer and pnpm are needed only for this repository's verification, packaging, -benchmark, and integration harnesses. They are not application dependencies and are not required by -the Rust crates. +Node.js 22.13 or newer and pnpm are needed for this repository's verification, packaging, benchmark, +and integration harnesses. They are not dependencies of the Rust crates. Node is also required when +using the optional repository-hosted pnpm launcher described below; that launcher never downloads a +binary during installation or execution. Registry consumers should use exact versions for the compatibility unit: ```toml [dependencies] -pliego-css = "=0.1.0-rc.2" +pliego-css = "=0.1.0-rc.3" [build-dependencies] -pliego-css-build = "=0.1.0-rc.2" +pliego-css-build = "=0.1.0-rc.3" ``` Install the CLI with the same exact candidate: ```console -cargo install pliego-cssc --version '=0.1.0-rc.2' --locked +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked ``` +## Repository-hosted pnpm package + +The G5 Node distribution is an npm-format package, but PliegoCSS does **not** publish it to npmjs. +It is built as one universal `.tgz` containing the declared native CLI/LSP binaries and is attached +only to an immutable GitHub Release. The package has zero dependencies and no lifecycle scripts. + +`0.1.0-rc.2` predates this contract and does not carry that asset. RC.3 must +publish the G5 bundle before this installation path becomes available. Once +the release page contains it, download and verify the exact asset before asking +pnpm to install the local file: + +```console +gh release download --repo celiumsai/pliegocss --pattern 'pliegocss-pnpm-*.tgz' +gh release verify-asset ./pliegocss-pnpm-.tgz --repo celiumsai/pliegocss +gh attestation verify ./pliegocss-pnpm-.tgz --repo celiumsai/pliegocss +pnpm add --save-dev --save-exact ./pliegocss-pnpm-.tgz +pnpm exec pliego-cssc --version +pnpm exec pliego-css-lsp --version +``` + +Do not install from a branch, a moving Git reference, npmjs, or an unverified remote tarball. Commit +the resulting `pnpm-lock.yaml`; pnpm is the supported and recommended package manager for this path. +Windows x64, Linux x64 GNU, and macOS arm64 are the declared binary hosts. Use Cargo from an exact +version or checkout on any other Rust target. + ## Install the command-line compiler From an exact PliegoCSS checkout, install the workspace binary with its lockfile: @@ -68,7 +94,7 @@ Add the public facade to the application: ```toml [dependencies] -pliego-css = "=0.1.0-rc.2" +pliego-css = "=0.1.0-rc.3" ``` Use a visible string literal so the macro can validate it during compilation: @@ -102,10 +128,10 @@ Add the build bridge when the package has a custom `pliego.theme.toml` or DTCG R ```toml [dependencies] -pliego-css = "=0.1.0-rc.2" +pliego-css = "=0.1.0-rc.3" [build-dependencies] -pliego-css-build = "=0.1.0-rc.2" +pliego-css-build = "=0.1.0-rc.3" ``` Create `build.rs`: diff --git a/docs/getting-started/pliegors.md b/docs/getting-started/pliegors.md index ee04c71..4e1922a 100644 --- a/docs/getting-started/pliegors.md +++ b/docs/getting-started/pliegors.md @@ -1,55 +1,62 @@ # PliegoRS + PliegoCSS from an empty project -Status: implemented registry workflow for PliegoRS `0.0.2` and PliegoCSS -`0.1.0-rc.2` +Status: implemented source-pinned workflow for PliegoRS +`abb8653e75da4a5cb4dd9b51200114fbb1e760c7` and PliegoCSS `0.1.0-rc.3` This guide starts with `pliego new` and ends with deterministic SSR/SSG output that can contain PliegoCSS classes, route and island CSS partitions, resumable state, and ordinary external CSS. -Both exact public-preview package units are available from crates.io. +The topology seam is newer than the published PliegoRS beta and is currently +consumed from the exact source revision above. PliegoCSS RC.3 remains under +exact-source verification until its coordinated publication gates pass. ## What the integration does today PliegoCSS emits standard CSS and stable class names. It does not add a browser styling runtime. PliegoRS owns views, SSR, resumability, SSG publication, and asset delivery. -The current integration automatically collects typed component/route/island reachability and derives -`shared`, route, island, and `unreachable` source partitions from one `ProductRegistry`. The bridge -from the PliegoRS registry to `pliego-css-source::ApplicationTopology` is still application-owned. -It is implemented and tested in the executable fixture, but it is not yet a one-command PliegoRS +The current integration consumes the canonical `pliegors-product-topology/1` +snapshot and derives typed component/route/island reachability plus `shared`, +route, island, and `unreachable` source partitions. It is implemented and +tested in the executable fixture, but it is not yet a one-command PliegoRS starter feature. ## 1. Scaffold the PliegoRS application -Install the exact PliegoRS CLI and create the project from its registry-backed -starter: +Build the exact PliegoRS source revision and create the project from its +registry-backed starter: ```console -cargo +1.85.0 install pliego-cli --version '=0.0.2' --locked +git clone https://github.com/celiumsai/pliegors.git ../pliegors +git -C ../pliegors checkout abb8653e75da4a5cb4dd9b51200114fbb1e760c7 +cargo +1.86.0 install --path ../pliegors/crates/pliego-cli --locked pliego new my-site cd my-site pliego check ``` -`pliego new` creates a standalone Rust 2024 project, standard asset directory, `pliego.toml`, and -an initial deterministic SSG application. The published `0.0.2` framework -supports Rust 1.85; the current unreleased PliegoRS `main` branch has a separate -Rust 1.86 requirement. +`pliego new` creates a standalone Rust 2024 project, standard asset directory, +`pliego.toml`, and an initial deterministic SSG application. PliegoRS +source revision requires Rust 1.86. PliegoCSS keeps Rust 1.85 as its independent +library MSRV. ## 2. Add the PliegoCSS and resumability crates Add these exact entries to the generated `[dependencies]`: ```toml -pliego-css = "=0.1.0-rc.2" -pliego-css-source = "=0.1.0-rc.2" -pliego-macros = "=0.0.2" -pliego-resume = "=0.0.2" +pliego-css = "=0.1.0-rc.3" +pliego-css-source = "=0.1.0-rc.3" +pliego-dom = { version = "=0.4.0-beta.1", path = "../pliegors/crates/pliego-dom" } +pliego-macros = { version = "=0.4.0-beta.1", path = "../pliegors/crates/pliego-macros" } +pliego-resume = { version = "=0.4.0-beta.1", path = "../pliegors/crates/pliego-resume" } +pliego-ssg = { version = "=0.4.0-beta.1", path = "../pliegors/crates/pliego-ssg" } ``` -Keep the generated `pliego-dom` and `pliego-ssg` dependencies. Then refresh and verify the lockfile: +Replace the generated `pliego-dom` and `pliego-ssg` entries with the exact +paths above. Then refresh and verify the lockfile: ```console -cargo +1.85.0 generate-lockfile +cargo +1.86.0 generate-lockfile pliego check pliego css check --seed ``` @@ -104,15 +111,18 @@ use pliego_ssg::{ProductIsland, ProductRegistry, ProductRoute}; pub fn application_registry() -> ProductRegistry { let home = crate::styles::home::component(); let counter = crate::styles::counter::component(); + let visit = crate::styles::visit::component(); let home_id = home.id().to_owned(); let counter_id = counter.id().to_owned(); + let visit_id = visit.id().to_owned(); ProductRegistry::new() .component(home) .component(counter) + .component(visit) .island(ProductIsland::new("visit-counter", "visit-counter").component(counter_id)) .route(ProductRoute::new("home", "/").component(home_id)) - .route(ProductRoute::new("visit", "/visit").island("visit-counter")) + .route(ProductRoute::new("visit", "/visit").component(visit_id).island("visit-counter")) } ``` @@ -121,8 +131,9 @@ rejects duplicate IDs, unsafe routes, missing component references, and missing ## 5. Derive reachability and CSS partitions -The application adapter converts the immutable `ProductRegistry` into -`pliego_css_source::ApplicationTopology`, scans the exact Cargo/rustc source inventory, and emits: +The application serializes the immutable `ProductRegistry` with +`to_topology_json()`. `pliego_css_source::ProductTopology` consumes that closed +snapshot, scans the exact Cargo/rustc source inventory, and emits: - canonical reachability schema 1; - an automatic bundle plan for shared, route, island, and unreachable sources; @@ -132,7 +143,7 @@ The application adapter converts the immutable `ProductRegistry` into Use these fixture files as the current executable implementation: - [registry shared by collection and SSG](../../integration-tests/pliegors-smoke/src/product.rs) -- [registry-to-topology and automatic partition adapter](../../integration-tests/pliegors-smoke/src/css_adapter.rs) +- [snapshot consumer and automatic partition adapter](../../integration-tests/pliegors-smoke/src/css_adapter.rs) - [collector entry point](../../integration-tests/pliegors-smoke/src/bin/collector.rs) - [verified SSG consumer](../../integration-tests/pliegors-smoke/src/bin/ssg.rs) diff --git a/docs/how-to/custom-breakpoints.md b/docs/how-to/custom-breakpoints.md index be31d21..552d442 100644 --- a/docs/how-to/custom-breakpoints.md +++ b/docs/how-to/custom-breakpoints.md @@ -38,7 +38,13 @@ content-wide = "72rem" `md` keeps its seed breakpoint ID and receives a new minimum width. `tablet` and `content-wide` are new named variants. Accepted units are `px`, `rem`, `em`, `ch`, and `vw`; each value must be finite -and greater than zero. +and greater than zero. Use one unit across the complete active set. Mixed units fail closed instead +of letting a name or stable ID accidentally decide responsive precedence. + +Breakpoint IDs remain stable lookup identity. PliegoCSS separately derives a narrow-to-wide cascade +rank from the numeric widths, so `tablet = "52rem"` is emitted before +`content-wide = "72rem"` even though canonical name ordering assigns `content-wide` the earlier ID. +The same rank controls viewport media queries and container queries. ## 3. Use the variants in visible literals @@ -76,3 +82,11 @@ Cargo reachability analysis. For the programmatic emission path, see [How token values reach CSS](../learn/themes-and-tokens.md#how-token-values-reach-css). For the complete validation rules, see the [theme schema](../reference/theme-schema.md). + +The repository freezes this exact 52/72rem regression with 2,048 ID/name/input permutations and +real computed-style checks in both Chrome and Edge: + +```console +pnpm integration:browser:breakpoints:chrome +pnpm integration:browser:breakpoints:edge +``` diff --git a/docs/how-to/pliegors-dev-loop.md b/docs/how-to/pliegors-dev-loop.md index c7d150f..7ccab9d 100644 --- a/docs/how-to/pliegors-dev-loop.md +++ b/docs/how-to/pliegors-dev-loop.md @@ -1,6 +1,6 @@ # PliegoRS development reload loop -Status: automated local two-process/SSE contract certified; controlled Chromium replay observed +Status: source-pinned local two-process/SSE gate green; controlled Chromium replay observed PliegoCSS does not need its own HTTP or WebSocket server. The current PliegoRS development server already owns browser reload through its loopback-only `pliego dev` server and `/_pliego/reload` SSE @@ -32,16 +32,16 @@ Rust/theme edit → the browser's existing EventSource triggers a full-page reload ``` -The cross-repository gate pins the PliegoRS CLI source, runs both processes with Rust 1.85, and +The cross-repository gate pins the PliegoRS CLI source, runs both processes with Rust 1.86, and verifies 20 alternating `pc!` edits plus invalid and unchanged-output negative cases. Run it with: ```console pnpm integration:pliegors-dev ``` -On Debian WSL2 with native Linux binaries and target directory, changed CSS reached disk at 232.4 ms -p50 / 284.5 ms p95. Matching HTML/CSS and exactly one stable SSE generation converged at 2.008 s -p50 / 2.458 s p95. These are server/site convergence measurements. A controlled local Chromium +On native Linux x64, changed CSS reached disk at 228.755 ms p50 / 229.629 ms p95. +Matching HTML/CSS and exactly one stable SSE generation converged at 1.696 s +p50 / 1.698 s p95. These are server/site convergence measurements. A controlled local Chromium replay separately observed `p-4`/16 px become `p-6`/24 px in the same tab without manual navigation. See the [development-loop evidence](../benchmarks/pliegors-dev-loop.md). @@ -50,13 +50,17 @@ replaced, so its modification time does not cause a redundant PliegoRS rebuild. changes, the manifest may be replaced while the CSS asset remains untouched. The negative gate uses an external-input no-op for which both CSS and manifest remain exact, including modification times. Invalid PliegoCSS input keeps the complete last valid publication group. +PliegoRS exposes its bounded build diagnostic at HTTP 500 for that failed +generation, then recovers through the same SSE channel after valid source is +restored. The adjacent `.pliego.lock`, `.tmp`, and `.bak` files are a reserved publication-coordination namespace, not application assets. The pinned PliegoRS watcher ignores them so a held advisory lock cannot trigger a scan failure or rebuild; it still observes every changed final CSS/manifest file. -Before rebuilding, `pliego dev` requires one 500 ms quiet snapshot and restarts that window whenever -the source/publication group changes. This coalesces a Rust edit and its generated assets into one -successful SSE generation. +`pliego dev` filters access-only notifications before they can starve real +filesystem changes and does not publish another generation for a no-op build +with no changed artifacts. This coalesces a Rust edit and its generated assets +into one successful SSE generation. Native Windows/Linux filesystem events schedule source captures; exact snapshots are confirmed across two captures separated by up to 100 ms before compilation. A 2 s fallback capture covers missed events, diff --git a/docs/index.md b/docs/index.md index 84c161e..d0d98ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,6 +59,7 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi - [Critical-style evidence schema 1](./reference/critical-style-evidence-schema-1.md) - [Command-line compiler](./reference/cli.md) - [Language Server Protocol transport](./reference/lsp.md) +- [Shared compiler engine](./reference/engine-api.md) - [LSP diagnostic corpus schema 2](./reference/lsp-diagnostic-corpus.md) - [Public API candidate](./reference/public-api.md) - [Declarative bundle-plan schema](./reference/bundle-plan.md) @@ -91,6 +92,7 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi - [Historical semantic IR binary format 1](./reference/semantic-ir-binary-v1.md) - [Compatibility candidate contract](./reference/compatibility.md) - [Compatibility policy schema 2](./reference/compatibility-policy.md) +- [Frozen adapter support policy for 0.1.x](./reference/adapter-support-policy-0.1.x.md) - [Initial utility catalog (F0 plan)](./reference/initial-utility-catalog.md) - [Glossary](./glossary.md) @@ -100,6 +102,7 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi - [Strategic CSS/Rust/AI product contract and R0-R3 gates](./product/strategic-product-contract-2026.md) - [Product maturity map](./product/maturity-map.md) - [Representative applications and selected gaps](./product/representative-applications-gap-map.md) +- [G7 external adoption evidence](./product/external-adoption-g7.md) - [R0 unified manifest and receipt design](./product/r0-manifest-receipt-design.md) - [Typed IR](./concepts/typed-ir.md) - [Conflict model](./concepts/conflict-model.md) @@ -109,8 +112,11 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi ## Status and evidence -- [0.1.0-rc.2 hardening report](./product/hardening-report-0.1.0-rc.2.md) -- [0.1.0 release readiness](./product/release-readiness-0.1.0.md) +- [0.1.0 release readiness — current generated view](./product/release-readiness-0.1.0.md) +- [0.1.0 release readiness — schema-3 machine authority](./product/release-readiness-0.1.0.json) +- [0.1.0-rc.2 hardening report — historical](./archive/releases/0.1.0-rc.2-hardening-report.md) +- [0.1.0-rc.3 candidate release notes](./releases/0.1.0-rc.3.md) +- [Historical evidence archive](./archive/README.md) - [F0 verification](./status/f0-verification.md) - [F1 parser and IR](./status/f1.md) - [F2 semantic compiler](./status/f2.md) @@ -127,8 +133,9 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi - [Packaging contract](./status/packaging.md) - [Gate A status](./status/gate-a.md) - [Gate A benchmark](./benchmarks/pliego-gate-a.md) -- [Gate B complete-fixture benchmark](./benchmarks/pliego-gate-b.md) -- [Competitive matrix against Tailwind CSS v4.3.2](./benchmarks/tailwind-v4-competitive-matrix.md) +- [Historical Gate B complete-fixture benchmark](./benchmarks/pliego-gate-b.md) +- [Tailwind Benchmark Authority v2](./benchmarks/tailwind-benchmark-authority-v2.md) +- [Generated competitive matrix against Tailwind CSS v4.3.3](./benchmarks/tailwind-v4-competitive-matrix.md) - [Representative application 1: plain HTML audit-first](./benchmarks/representative-plain-html-audit.md) - [Representative application 2: Vite/Tailwind inventory-first](./benchmarks/representative-vite-tailwind.md) - [Representative application 3: CSS Modules consumer](./benchmarks/representative-css-modules.md) @@ -138,9 +145,11 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi - [Rust check baseline](./benchmarks/rust-check-baseline.md) - [Frozen machine snapshots](../benchmarks/evidence/README.md) - [Browser validation](./benchmarks/browser-validation.md) +- [Browser/output certification v1](./benchmarks/browser-output-certification-v1.md) - [Hosted browser/OS evidence matrix](./benchmarks/hosted-browser-matrix.json) - [PliegoRS development-loop latency and browser reload](./benchmarks/pliegors-dev-loop.md) - [Migration inventory and plan adoption latency](./benchmarks/migration-adoption-latency.md) +- [External adoption intake and evidence custody](./contributing/external-adoption-intake.md) - [WASM runtime overhead](./benchmarks/wasm-overhead.md) ## Contributing and releases diff --git a/docs/integrations/pliegors.md b/docs/integrations/pliegors.md index 704ae7f..39c863c 100644 --- a/docs/integrations/pliegors.md +++ b/docs/integrations/pliegors.md @@ -77,8 +77,9 @@ pliego-cssc bundle \ `ProductRegistry` es la fuente explícita de componentes, rutas, islands y ocurrencia route→island. Cada componente usa `product_component!` para capturar el `file!()` normalizado de su declaración; -esto evita duplicar el path, pero no descubre componentes no registrados. El adapter transforma un -snapshot validado en `ApplicationTopology`, inventaría los Rust sources y genera el sidecar neutral +esto evita duplicar el path, pero no descubre componentes no registrados. PliegoRS emite el +snapshot canónico `pliegors-product-topology/1`; `ProductTopology` lo valida, +inventaría los Rust sources y genera el sidecar neutral de [reachability schema 1](../reference/reachability-schema.md). También agrupa cada source por su conjunto exacto de raíces route/island y genera el bundle plan; ya no hay JSON ni TOML de ownership mantenido a mano. El collector rechaza una invocación visible `pc!`/`pcx!` sin dueño, un site exacto @@ -127,7 +128,9 @@ resumability; no debe presentarse como hidratación. ## Compatibilidad -PliegoCSS y PliegoRS declaran Rust 1.85 como versión mínima. La suite del workspace de PliegoCSS se verifica también con ese toolchain. +PliegoCSS declara Rust 1.85 como versión mínima; PliegoRS `0.4.0-beta.1` +requiere Rust 1.86. La suite del workspace de PliegoCSS se verifica también con +su toolchain mínimo independiente. El gate cruzado vive en `integration-tests/pliegors-smoke` y presupone que `PliegoCSS` y `pliegors` son directorios hermanos. Es un workspace separado con `Cargo.lock` versionado; usa `pliego-dom` y @@ -135,11 +138,12 @@ son directorios hermanos. Es un workspace separado con `Cargo.lock` versionado; builder DOM, SSR directo, ramas precompiladas de `pcx!` y el sitio de dos rutas: ```console -cargo +1.85 test --locked --manifest-path integration-tests/pliegors-smoke/Cargo.toml +cargo +1.86 test --locked --manifest-path integration-tests/pliegors-smoke/Cargo.toml ``` -La superficie fijada incluye también `Cargo.lock`, `crates/pliego-starters/**` y -`crates/pliego-cli/**`. El gate F6 separado materializa +La superficie fijada incluye también `Cargo.lock`, `crates/pliego-starters/**`, +`crates/pliego-cli/**` y `schemas/pliego.product-topology.schema.json`. El gate +F6 separado materializa `integration-tests/pliegors-dev-loop`, levanta `pliego-cssc watch` y `pliego dev`, alterna ediciones válidas 20 veces, comprueba CSS servido contra artifact, padding semántico, ausencia de clase stale, una sola generación SSE estable y conserva el grupo de publicación ante errores. Puede ejecutarse diff --git a/docs/learn/themes-and-tokens.md b/docs/learn/themes-and-tokens.md index c5a5cfc..82cff22 100644 --- a/docs/learn/themes-and-tokens.md +++ b/docs/learn/themes-and-tokens.md @@ -189,7 +189,7 @@ Cargo macro uses the direct Resolver form above rather than reading the bundle p ## Identity is a correctness boundary Changing any token or breakpoint changes `ThemeId`, even if one particular style does not reference -the changed definition. StyleId format 2 encodes both `THEME_ID_FORMAT_VERSION = 2` and the complete +the changed definition. StyleId format 2 encodes both `THEME_ID_FORMAT_VERSION = 3` and the complete `ThemeId` before hashing the tagged semantic stream with SHA-256. This partitions identities from different registries. The emitter independently recomputes the expected identity and reports a mismatch if a style compiled under one theme is emitted under diff --git a/docs/product/adapter-support-policy-0.1.x.json b/docs/product/adapter-support-policy-0.1.x.json new file mode 100644 index 0000000..702ca96 --- /dev/null +++ b/docs/product/adapter-support-policy-0.1.x.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-adapter-support-policy", + "status": "frozen-for-0.1-promotion", + "releaseLine": "0.1.x", + "frozenAt": "2026-07-25", + "protocol": { + "name": "static-complete-class-group-coexistence", + "authoritySchema": 1, + "migrationInventorySchema": 1 + }, + "supportedTailwindProfiles": [ + { + "id": "tailwind-v3-lts", + "certifiedVersion": "3.4.19", + "sourceContract": "v3-directives-no-preflight" + }, + { + "id": "tailwind-v4-current", + "certifiedVersion": "4.3.3", + "sourceContract": "v4-import-no-preflight" + } + ], + "supportedAdapters": [ + { + "id": "html", + "supportTier": "certified", + "inputContract": "complete-static-html-document", + "version": null + }, + { + "id": "vite", + "supportTier": "certified", + "inputContract": "vite-production-build", + "version": "8.1.5" + }, + { + "id": "pliegors", + "supportTier": "certified", + "inputContract": "rendered-static-html-plus-pinned-framework-browser-replay", + "version": "pliego-dom=0.0.2;pliego-ssg=0.0.2" + } + ], + "migration": { + "mode": "coexistence", + "tailwindRuntimeRetained": true, + "applyUnit": "complete-literal-class-group", + "rollback": "exact-source-bytes", + "dynamicClasses": "unsupported-fail-closed", + "arbitraryConfigOrPluginExecution": "unsupported-fail-closed", + "tailwindRemoval": "not-certified" + }, + "verification": { + "requiredHosts": [ + "windows-x64-chromium", + "linux-x64-chromium", + "macos-arm64-chromium" + ], + "requiredComparisons": [ + "dom", + "aria", + "computed-style", + "layout-geometry", + "exact-rollback" + ], + "maximumLayoutGeometryDeltaCssPx": 0.1 + }, + "supportTiers": { + "certified": "Exact versions and bounded inputs replayed by the promotion authority.", + "best-effort": "Accepted for issue triage but not covered by the promotion claim.", + "unsupported": "Rejected or left unchanged; no compatibility or migration claim." + }, + "claimBoundary": "The 0.1.x promoted adapter claim covers only literal complete class groups in static HTML, Vite 8.1.5 production builds, and pinned PliegoRS rendered output, coexisting with Tailwind CSS 3.4.19 or 4.3.3 without Preflight. It does not certify dynamic class construction, arbitrary Tailwind configuration or plugin execution, unsupported utilities, Tailwind removal, or other framework versions." +} diff --git a/docs/product/document-authority.json b/docs/product/document-authority.json new file mode 100644 index 0000000..96e412c --- /dev/null +++ b/docs/product/document-authority.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-document-authority", + "currentState": { + "machine": "docs/product/release-readiness-0.1.0.json", + "human": "docs/product/release-readiness-0.1.0.md", + "generatedBy": "scripts/render-release-readiness.mjs" + }, + "siteProjection": { + "source": "docs/site/release-readiness.md", + "route": "/docs/release-readiness/" + }, + "normative": [ + "docs/product/strategic-product-contract-2026.md", + "docs/product/maturity-map.json", + "benchmarks/external-adoption-v1/authority.json", + "docs/product/adapter-support-policy-0.1.x.json", + "docs/contributing/release-process.md" + ], + "historical": [ + { + "path": "docs/archive/releases/0.1.0-rc.2-hardening-report.md", + "supersededBy": "docs/product/release-readiness-0.1.0.json" + }, + { + "path": "docs/archive/product/next-gap-2026-07-20.md", + "supersededBy": "docs/product/release-readiness-0.1.0.json" + }, + { + "path": "docs/archive/product/next-gap-2026-07-20.json", + "supersededBy": "docs/product/release-readiness-0.1.0.json" + }, + { + "path": "docs/archive/evidence/hosted-browser-matrix-2026-07-19.json", + "supersededBy": "docs/benchmarks/hosted-browser-matrix.json" + } + ], + "rules": [ + "Exactly one machine document has current release-state authority.", + "The human current-state page is generated byte-for-byte from that machine document.", + "Historical files never satisfy current checks unless schema 3 binds exact commit, tree, validity, coverage, environment, URL, and artifact hash.", + "External adoption passes only through the hash-bound G7 authority; owned fixtures and synthetic incidents never count.", + "Recruitment issues and correspondence never satisfy consent, review, or evidence coverage by themselves.", + "The website status route consumes the generated Markdown instead of duplicating release prose in Rust." + ] +} diff --git a/docs/product/evidence/v0.1.0-rc.2-source.json b/docs/product/evidence/v0.1.0-rc.2-source.json new file mode 100644 index 0000000..6988598 --- /dev/null +++ b/docs/product/evidence/v0.1.0-rc.2-source.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-release-source-evidence", + "recordedAt": "2026-07-22T14:10:00Z", + "source": { + "tag": "v0.1.0-rc.2", + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observations": [ + { + "id": "github-actions-ci", + "status": "passed", + "observedAt": "2026-07-22T14:05:00Z", + "expiresAt": "2026-07-29T14:05:00Z", + "url": "https://github.com/celiumsai/pliegocss/actions/runs/29864046865", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "coverage": [ + "ubuntu-rust-1.85", + "ubuntu-rust-1.96", + "windows-rust-1.85", + "windows-rust-1.96", + "macos-rust-1.85", + "macos-rust-1.96", + "package-archives", + "supply-chain", + "wasm-diagnostics" + ] + }, + { + "id": "github-actions-codeql", + "status": "passed", + "observedAt": "2026-07-22T14:05:00Z", + "expiresAt": "2026-07-29T14:05:00Z", + "url": "https://github.com/celiumsai/pliegocss/actions/runs/29864046876", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "coverage": ["actions", "javascript-typescript", "rust"] + }, + { + "id": "github-release", + "status": "published-prerelease", + "observedAt": "2026-07-22T14:05:00Z", + "expiresAt": "2026-07-29T14:05:00Z", + "url": "https://github.com/celiumsai/pliegocss/releases/tag/v0.1.0-rc.2", + "sourceCommit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133", + "coverage": ["tag", "release-notes"] + } + ], + "claimBoundary": "This snapshot proves only the listed external observations for the exact RC.2 commit and tree. It does not cover the later G0/G1 worktree, browser certification, adoption, or final authorization." +} diff --git a/docs/product/external-adoption-g7.md b/docs/product/external-adoption-g7.md new file mode 100644 index 0000000..a9012c7 --- /dev/null +++ b/docs/product/external-adoption-g7.md @@ -0,0 +1,58 @@ +# G7 external adoption evidence + + + +Status: **blocked** + +G7 is an external-evidence gate, not a fixture-writing milestone. Its machine authority is +[`benchmarks/external-adoption-v1/authority.json`](../../benchmarks/external-adoption-v1/authority.json). +The public ledgers contain only consented, redacted, reviewed records; raw material remains in +private custody and is represented by a stable SHA-256 digest. + +## Recruitment + +Recruitment is **open**. The public cohort notice is +[GitHub issue #7](https://github.com/celiumsai/pliegocss/issues/7). Apply privately through +[hello@pliegocss.dev](mailto:hello@pliegocss.dev?subject=G7%20external%20adoption) after reading the +[privacy notice](https://pliegocss.dev/legal/privacy/). Public posts are recruitment signals +only and never count as consent or evidence. + +## Current qualified evidence + +| Evidence | Qualified | Required | +| --- | ---: | ---: | +| External interviews | 0 | 10–15 | +| Real incidents | 0 | 20 | +| Completed pilots | 0 | 3–5 | +| Independent projects | 0 | 3 | +| Independent organizations | 0 | 3 | + +The adapter policy is **frozen-for-0.1-promotion** for `0.1.x`. +Missing promotion coverage: `ten-to-fifteen-interviews`, `twenty-incidents`, `three-to-five-pilots`, `three-independent-projects`, `three-independent-organizations`. + +## Admission boundary + +- Interviews count once per pseudonymous participant and require recorded research/public-summary consent. +- Incidents must be observed in an external project. Synthetic conformance cases never count. +- Pilots must complete adoption, browser equivalence, and exact rollback with zero blocking failures. +- Pilot projects and organizations must be unique and outside Celiums/PliegoCSS ownership. +- Every record requires an approved review, a redacted public entry, and a digest for its private source. +- Draft notes, personal data, raw transcripts, access tokens, and proprietary source are never committed. + +The exact intake and custody workflow is documented in +[`external-adoption-intake.md`](../contributing/external-adoption-intake.md). The promoted adapter +scope is frozen in [`adapter-support-policy-0.1.x.json`](./adapter-support-policy-0.1.x.json). + +## Commands + +```console +pnpm check:external-adoption-authority +pnpm check:external-adoption +``` + +The first command validates an honest blocked or ready state. The second is a promotion gate and +fails until every required coverage item is present. + +## Claim boundary + +Only reviewed evidence from projects not owned or maintained by Celiums Solutions, PliegoCSS, or celiumsai counts. Owned fixtures, synthetic incidents, unreviewed notes, unconsented records, duplicate participants/projects, and unhashed private material never satisfy G7. diff --git a/docs/product/maturity-map.json b/docs/product/maturity-map.json index 908e7d3..a676253 100644 --- a/docs/product/maturity-map.json +++ b/docs/product/maturity-map.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "statusDate": "2026-07-19", + "statusDate": "2026-07-22", "releaseTarget": "0.1.0", "levels": { "stable": "Selected for the supported 0.1.x contract and protected by local deterministic gates; this classification activates as a SemVer promise only at RC.", @@ -15,7 +15,7 @@ "scope": "Visible literals, statically enumerable conditional branches, sealed Style construction, Cargo dependency renames, and class interoperability.", "owners": ["crates/pliego-css", "crates/pliego-css-macros", "crates/pliego-css-parser", "crates/pliego-css-compiler"], "schemas": ["StyleId format 2", "class-name format 1"], - "gates": ["check:api", "check:properties", "check:determinism"], + "gates": ["check:api", "check:properties", "check:determinism", "check:engine-boundary"], "graduationCriteria": ["Keep the selected public API compiling on Rust 1.85.", "Preserve frozen identity/class vectors for identical semantic inputs."], "limitations": ["The full public Rust workspace surface and crate boundaries are not stable application API."] }, @@ -26,7 +26,7 @@ "scope": "Semantic lowering, conflict detection, target transformation, pretty/minified output, and deterministic manifests for typed inputs.", "owners": ["crates/pliego-css-compiler", "crates/pliego-css-build", "crates/pliego-cssc"], "schemas": ["semantic IR format 2", "manifest schema 3"], - "gates": ["check:determinism", "check:properties", "check:packages"], + "gates": ["check:determinism", "check:properties", "check:packages", "check:engine-boundary"], "graduationCriteria": ["Retain byte identity and MSRV package replay.", "Keep backend target configuration versioned."], "limitations": ["This does not claim a complete arbitrary standard-CSS compile/minify product path."] }, @@ -36,7 +36,7 @@ "maturity": "stable", "scope": "Schema-1 TOML themes, theme! build integration, bounded input, and versioned theme identity/artifacts.", "owners": ["crates/pliego-css-theme", "crates/pliego-css-config", "crates/pliego-css-build"], - "schemas": ["theme configuration schema 1", "ThemeId format 2", "theme binary format 2"], + "schemas": ["theme configuration schema 1", "ThemeId format 3", "theme binary format 3"], "gates": ["check:api", "check:packages", "check:portability"], "graduationCriteria": ["Preserve format rejection and exact-version bridge behavior."], "limitations": ["Build helper implementation types remain internal."] @@ -47,7 +47,7 @@ "maturity": "stable", "scope": "Documented finite commands, structured failures, atomic outputs, and read-only check modes.", "owners": ["crates/pliego-cssc"], - "schemas": ["diagnostic schema 1", "inspect schema 2", "catalog schema 3", "explain schema 2"], + "schemas": ["diagnostic schema 1", "inspect schema 3", "catalog schema 4", "explain schema 2"], "gates": ["verify:fast", "check:getting-started", "check:packages"], "graduationCriteria": ["Keep documented flags and successful output bytes compatible through 0.1.x."], "limitations": ["Watch is classified separately because it is a long-lived producer."] @@ -151,36 +151,58 @@ "graduationCriteria": ["Add reviewed real-incident authority and efficacy evidence.", "Define hosted signer/approval protocol and required browser matrix."], "limitations": ["Synthetic authority evidence is not agent efficacy; browser evidence currently has one pinned Chromium profile."] }, + { + "id": "benchmark-authority-v2", + "title": "Paired Tailwind competitor benchmark authority", + "maturity": "beta", + "scope": "Fresh Tailwind latest, upstream v3-lts, and frozen-release lanes with alternating paired samples, peak working set, raw/gzip/Brotli output, and micro/medium/large corpora.", + "owners": ["benchmarks/benchmark-authority-v2", "scripts/measure-benchmark-authority-v2.mjs"], + "schemas": ["Benchmark Authority schema 2", "Benchmark result schema 2"], + "gates": ["check:benchmark-authority", "check:benchmark-oracle", "check:tailwind-matrix"], + "graduationCriteria": ["Record reviewed immutable schema-2 evidence from a clean source commit.", "Keep the primary oracle no more than seven days behind the live registry at measurement time."], + "limitations": ["Legacy Gate A/B snapshots are historical only; local smoke or machine-local samples do not authorize a current competitive score."] + }, + { + "id": "browser-output-certification-v1", + "title": "Computed and visual browser output certification against Tailwind", + "maturity": "beta", + "scope": "Frozen shared utility intent across responsive, hover, focus, explicit reset modes, 52 computed properties, 1/64-pixel box/text-line geometry bounded to 0.1 CSS px, and bounded PNG comparison on a 3×3 browser/OS matrix.", + "owners": ["benchmarks/browser-output-certification-v1", "scripts/browser-output-certification.mjs", "scripts/check-browser-output-matrix.mjs"], + "schemas": ["Browser/output authority schema 1", "host evidence schema 1", "matrix evidence schema 1"], + "gates": ["check:browser-output-authority", "check:browser-output", "check:browser-output-matrix"], + "graduationCriteria": ["Pass all nine clean hosted lanes on the exact release commit and Git tree.", "Retain current Tailwind provenance and unexpired hash-valid screenshot evidence."], + "limitations": ["Local three-engine Windows and Linux evidence is diagnostic; passing the frozen fixture is not general Tailwind compatibility or cross-engine pixel identity."] + }, { "id": "lsp-editor-hosts", "title": "LSP, VS Code, and Neovim clients", "maturity": "beta", - "scope": "Completion, hover, diagnostics, formatting, cancellation, and Project Index navigation over bounded full-sync documents.", - "owners": ["crates/pliego-css-lsp", "editors/vscode", "editors/neovim"], + "scope": "In-process completion, hover, diagnostics, formatting, stale-version suppression, and Project Index navigation over bounded full-sync documents.", + "owners": ["crates/pliego-css-compiler", "crates/pliego-css-lsp", "editors/vscode", "editors/neovim"], "schemas": ["LSP diagnostic corpus schema 2", "LSP 3.18 transport subset"], - "gates": ["integration:lsp", "integration:vscode", "integration:vscode-host", "integration:neovim"], + "gates": ["check:engine-boundary", "integration:lsp", "integration:vscode", "integration:vscode-host", "integration:neovim"], "graduationCriteria": ["Obtain hosted extension-host evidence on supported OSes.", "Publish and version client/server installation compatibility."], "limitations": ["Editor packages are unreleased and host gates require explicit environment configuration."] }, { "id": "migration-inventory", - "title": "Read-only migration inventory bridges", + "title": "Migration inventory and static adapter coexistence", "maturity": "beta", - "scope": "Bounded Sass, Tailwind v4, CSS Modules, consumer, and auxiliary inventory without executing source toolchains.", - "owners": ["crates/pliego-css-source", "crates/pliego-cssc"], - "schemas": ["migration inventory schema 1", "migration project inventory schema 1"], - "gates": ["check:migration-corpus", "check:migration-real-corpus"], - "graduationCriteria": ["Add representative scale and broader configured importer/load-path evidence.", "Deliver reversible codemods with visual verification for promoted migrations."], - "limitations": ["Role precision is not semantic migration accuracy; no perfect-migration claim is made."] + "scope": "Bounded Sass, Tailwind, CSS Modules, consumer, and auxiliary inventory plus literal complete-class-group coexistence across 21 HTML, Vite, and PliegoRS project snapshots with Tailwind v3/v4 output audit, browser equivalence, and exact rollback.", + "owners": ["crates/pliego-css-source", "crates/pliego-cssc", "benchmarks/adapter-coexistence-v1", "benchmarks/external-adoption-v1", "scripts/adapter-coexistence-certification.mjs"], + "schemas": ["migration inventory schema 1", "migration project inventory schema 1", "adapter coexistence authority schema 1", "adapter coexistence host/matrix evidence schema 1", "external adoption authority schema 1", "adapter support policy schema 1"], + "gates": ["check:migration-corpus", "check:migration-real-corpus", "check:adapter-coexistence-authority", "check:adapter-coexistence", "check:adapter-coexistence-matrix", "check:external-adoption-authority", "check:external-adoption", "integration:pliegors-browser"], + "graduationCriteria": ["Pass all three clean hosted adapter lanes on the exact promotion source and retain the pinned PliegoRS framework/browser replay.", "Obtain G7 evidence from at least three external projects and freeze the promoted adapter support policy."], + "limitations": ["Dynamic class construction, arbitrary Tailwind plugin/config execution, unsupported utilities, Tailwind removal, and general perfect-migration claims remain outside the bounded protocol."] }, { "id": "watch-loop", "title": "Exact-snapshot watch producer", "maturity": "beta", - "scope": "Event-assisted snapshot fallback, source/theme caches, last-valid output retention, and grouped publication.", - "owners": ["crates/pliego-css-watch", "crates/pliego-cssc"], + "scope": "Event-assisted snapshot fallback, shared AnalysisHost caches, last-valid output retention, and grouped publication.", + "owners": ["crates/pliego-css-compiler", "crates/pliego-css-watch", "crates/pliego-cssc"], "schemas": ["compile control manifest/receipt 1.0.0"], - "gates": ["verify:integration", "integration:pliegors-dev", "check:portability"], + "gates": ["check:engine-boundary", "verify:integration", "integration:pliegors-dev", "check:portability"], "graduationCriteria": ["Pass hosted long-running watch/recovery tests on supported OSes.", "Publish supported latency and resource envelopes."], "limitations": ["It regenerates artifacts; it is not CSS-only browser HMR."] }, @@ -230,14 +252,14 @@ }, { "id": "registry-distribution", - "title": "Registry packages and supported installation", + "title": "Registry crates and repository-hosted binary distribution", "maturity": "beta", - "scope": "Published exact-version 19-crate graph plus installable CLI and LSP artifacts.", - "owners": ["scripts/check-packages.mjs", "docs/contributing/release-process.md"], - "schemas": [], - "gates": ["check:packages", "verify:release"], - "graduationCriteria": ["Retain registry-only Rust 1.85 replay for every release.", "Publish and version editor client installation compatibility.", "Promote final 0.1.0 only through the separate readiness authorization."], - "limitations": ["The VS Code and Neovim clients remain source-distributed candidates; crates.io versions must be consumed as one exact compatibility unit."] + "scope": "Published exact-version 19-crate graph plus GitHub Release native CLI/LSP archives and one universal no-lifecycle pnpm package asset.", + "owners": ["scripts/check-packages.mjs", "scripts/check-repository-distribution.mjs", "distribution/repository-distribution-v1.json", ".github/workflows/distribution.yml", "docs/contributing/release-process.md"], + "schemas": ["repository distribution policy 1", "repository distribution manifest 1"], + "gates": ["check:packages", "check:repository-distribution", "verify:release"], + "graduationCriteria": ["Retain registry-only Rust 1.85 replay for every release.", "Build, attest, install, and execute exact-source repository assets on every declared host.", "Publish the npm-format package only as an immutable GitHub Release asset and install it with pnpm after verification.", "Publish and version editor client installation compatibility.", "Promote final 0.1.0 only through the separate readiness authorization."], + "limitations": ["npmjs publication is forbidden; the pnpm package contains no dependencies or lifecycle scripts and supports only Windows x64, Linux x64 GNU, and macOS arm64.", "GitHub/Sigstore provenance is not Authenticode signing or Apple notarization.", "The VS Code and Neovim clients remain source-distributed candidates; crates.io versions must be consumed as one exact compatibility unit."] } ] } diff --git a/docs/product/maturity-map.md b/docs/product/maturity-map.md index e055db0..a56853a 100644 --- a/docs/product/maturity-map.md +++ b/docs/product/maturity-map.md @@ -30,6 +30,9 @@ experimental because its intended product scope is explicitly broader than its s These claims are deliberately narrower than “all public Rust types,” “complete CSS,” or “published packages.” Their exact boundaries are enforced by the public API, compatibility, determinism, packaging, and portability gates named in the JSON map. +The pure `CompileRequest → CompileResult` engine, shared `PcxRequest` frontend, and +semantic/physical planner separation are implementation architecture inside that exact-version +unit; they do not expand the stable application API. ## Beta surface @@ -41,11 +44,17 @@ promise: - budgets, ownership, usage evidence, retention, and pruning; - static accessibility policy; - bounded agent repair and verification receipts; +- paired Tailwind Benchmark Authority v2 (latest, v3-LTS, and frozen regression lanes); +- computed and bounded visual browser-output certification against Tailwind across the declared + 3×3 host matrix; - LSP/editor clients; -- migration inventory; +- migration inventory plus bounded static Tailwind v3/v4 coexistence across 21 HTML, Vite, and + PliegoRS project snapshots with browser equivalence and exact rollback. Its 0.1.x adapter policy + is frozen, while the separate G7 authority remains honestly blocked at zero external records; - watch mode; - PliegoRS SSR/SSG/resumability integration; -- exact-version registry distribution for the published Rust workspace and CLI/LSP binaries. +- exact-version crates.io distribution for the Rust workspace plus repository-hosted native CLI/LSP + archives and a no-lifecycle pnpm package. The npm-format package is never published to npmjs. Each row in the machine map names the owning paths, persisted schemas, current gates, graduation criteria, and claim limits. A beta capability cannot graduate solely because its tests pass locally; diff --git a/docs/product/release-readiness-0.1.0.json b/docs/product/release-readiness-0.1.0.json index 253d25e..252bb8a 100644 --- a/docs/product/release-readiness-0.1.0.json +++ b/docs/product/release-readiness-0.1.0.json @@ -1,103 +1,329 @@ { - "schemaVersion": 2, + "schemaVersion": 3, + "kind": "pliegocss-release-readiness", + "state": "current", "targetVersion": "0.1.0", "candidateVersion": "0.1.0-rc.2", "repository": "https://github.com/celiumsai/pliegocss", - "repositoryVisibility": "public", "productStage": "public-preview", + "source": { + "tag": "v0.1.0-rc.2", + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", "result": "blocked", + "dimensions": { + "technicalReady": false, + "operationallyReady": false, + "adoptionReady": false, + "authorized": false, + "promotionReady": false + }, "checks": [ { - "id": "targeted-corrections", - "status": "passed", - "evidenceClass": "measured", + "id": "exact-candidate-ci", + "dimension": "technical", + "status": "pending", + "evidenceClass": "inherited", "requiredForPromotion": true, - "summary": "Focused Rust tests pass for physical !important propagation, migration revalidation, LSP bounds, repair-agent bounds, package paths, media merging, and CLI help." + "summary": "GitHub Actions run 29864046865 passed the exact RC.2 commit across Ubuntu, Windows, and macOS with Rust 1.85 and 1.96 plus the listed release jobs, but its promotion window has expired and requires a fresh candidate replay.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:05:00Z", + "expiresAt": "2026-07-29T14:05:00Z", + "environment": { + "os": "github-hosted-ubuntu-windows-macos", + "arch": "x86_64-arm64-hosted", + "rust": "1.85.0,1.96.0", + "node": "22.13+", + "browser": null + }, + "requiredCoverage": ["ubuntu-rust-1.85", "ubuntu-rust-1.96", "windows-rust-1.85", "windows-rust-1.96", "macos-rust-1.85", "macos-rust-1.96", "package-archives", "supply-chain", "wasm-diagnostics"], + "coveredCoverage": ["ubuntu-rust-1.85", "ubuntu-rust-1.96", "windows-rust-1.85", "windows-rust-1.96", "macos-rust-1.85", "macos-rust-1.96", "package-archives", "supply-chain", "wasm-diagnostics"], + "waiverAdr": null, + "artifacts": [ + { + "path": "docs/product/evidence/v0.1.0-rc.2-source.json", + "url": "https://github.com/celiumsai/pliegocss/blob/main/docs/product/evidence/v0.1.0-rc.2-source.json", + "sha256": "sha256:51afb97c3b86fbd34ad7d827e7fc5347f1e8d85e5dd1456e44d0c9389a82fecb" + } + ], + "references": [ + { + "kind": "github-actions-run", + "url": "https://github.com/celiumsai/pliegocss/actions/runs/29864046865" + } + ] }, { - "id": "brand-system", - "status": "passed", - "evidenceClass": "measured", - "requiredForPromotion": false, - "summary": "The automated brand contract passes for the versioned brandbook, seven vector variants, two exact-dimension raster exports, font licenses, fourteen DTCG-to-CSS token projections, six reviewed GPT Image 2 masters, their AVIF/WebP derivatives, prompt specifications, and generation manifest." - }, - { - "id": "fast-profile-current-source", - "status": "passed", - "evidenceClass": "measured", + "id": "exact-candidate-codeql", + "dimension": "technical", + "status": "pending", + "evidenceClass": "inherited", "requiredForPromotion": true, - "summary": "Every fast-profile constituent is measured on the current working source: Windows passes formatting, cargo check, and JavaScript documentation/product gates; WSL passes the complete locked workspace tests, doctests, and Clippy with warnings denied. Windows Application Control error 4551 still prevents a single-environment local replay, so hosted Windows remains a separate required check." + "summary": "CodeQL run 29864046876 passed Actions, JavaScript/TypeScript, and Rust analysis on the exact RC.2 commit, but its promotion window has expired and requires a fresh candidate replay.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:05:00Z", + "expiresAt": "2026-07-29T14:05:00Z", + "environment": { + "os": "github-hosted-ubuntu", + "arch": "x86_64", + "rust": "workflow-managed", + "node": "workflow-managed", + "browser": null + }, + "requiredCoverage": ["actions", "javascript-typescript", "rust"], + "coveredCoverage": ["actions", "javascript-typescript", "rust"], + "waiverAdr": null, + "artifacts": [ + { + "path": "docs/product/evidence/v0.1.0-rc.2-source.json", + "url": "https://github.com/celiumsai/pliegocss/blob/main/docs/product/evidence/v0.1.0-rc.2-source.json", + "sha256": "sha256:51afb97c3b86fbd34ad7d827e7fc5347f1e8d85e5dd1456e44d0c9389a82fecb" + } + ], + "references": [ + { + "kind": "github-actions-run", + "url": "https://github.com/celiumsai/pliegocss/actions/runs/29864046876" + } + ] }, { - "id": "pliegors-current-contract", - "status": "passed", - "evidenceClass": "measured", - "requiredForPromotion": true, - "summary": "The WSL integration passed against remote-reachable PliegoRS revision f3f4eb9 with registry-replayable 0.0.2 fixture locks." - }, - { - "id": "browser-current-source", - "status": "passed", - "evidenceClass": "measured", - "requiredForPromotion": true, - "summary": "Chrome 150 passes computed layout, WebGL/Three.js readiness, real compiler playground interaction, desktop/mobile overflow, and representative site routes on the current working source." - }, - { - "id": "package-replay-current-source", - "status": "passed", - "evidenceClass": "measured", + "id": "g0-corrections-on-candidate", + "dimension": "technical", + "status": "pending", + "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "Hosted package archives pass for the public-preview source, all nineteen 0.1.0-rc.2 archives remain below the 72 KiB compressed limit, and the published compatibility unit replays through a registry-only Rust 1.85 downstream public-API fixture." + "summary": "G0 corrections are not part of the exact RC.2 candidate. They require a newly named candidate plus hosted replay before they can become release evidence.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "pending-hosted-matrix", + "arch": "pending", + "rust": "1.85.0,1.96.0", + "node": "22.13+", + "browser": "chrome,edge" + }, + "requiredCoverage": ["breakpoints-52-72", "migration-empty", "css-rule-walker", "toolchain-isolation"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [] }, { - "id": "benchmark-evidence-current-source", - "status": "passed", - "evidenceClass": "measured", + "id": "browser-release-matrix", + "dimension": "technical", + "status": "blocked", + "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "Gate A, Gate B, and paired Rust MSRV snapshots were regenerated from reachable clean commit d16fe5da26d4943ebcfd70ce7c14e743d1e25e54, hash-allowlisted, and independently verified against the exact source blobs." + "summary": "Every required hosted browser lane is not configured for the exact candidate source; local or historical browser runs have no promotion authority.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "windows-linux-macos", + "arch": "x86_64-arm64", + "rust": null, + "node": null, + "browser": "chrome,edge,firefox,chromium,safari" + }, + "requiredCoverage": ["windows-chrome", "windows-edge", "windows-firefox", "linux-chromium", "macos-safari"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [ + { + "kind": "current-matrix", + "url": "https://github.com/celiumsai/pliegocss/blob/main/docs/benchmarks/hosted-browser-matrix.json" + } + ] }, { - "id": "supply-chain-policy", - "status": "passed", - "evidenceClass": "measured", + "id": "adapter-coexistence-matrix", + "dimension": "technical", + "status": "blocked", + "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "cargo-audit reports zero advisories and cargo-deny passes licenses, bans, and sources under WSL. CI deliberately runs those non-overlapping checks instead of making cargo-deny fetch a second advisory database after cargo-audit." + "summary": "RC.2 predates the G6 adapter authority. It has no clean exact-source 21-project HTML/Vite/PliegoRS matrix, Tailwind v3/v4 source/output audit, DOM/ARIA/style equivalence, exact rollback, or pinned PliegoRS framework replay.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "github-hosted-windows-linux-macos", + "arch": "x86_64-arm64", + "rust": "1.96.0,1.86.0-pliegors", + "node": "22.13+", + "browser": "chromium" + }, + "requiredCoverage": ["twenty-one-projects", "html-seven", "vite-seven", "pliegors-seven", "tailwind-v3-lts", "tailwind-v4-current", "tailwind-source-output-audit", "dom-aria-style-geometry", "exact-rollback", "windows-x64", "linux-x64", "macos-arm64", "pinned-pliegors-framework-browser"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [ + { + "kind": "adapter-coexistence-workflow", + "url": "https://github.com/celiumsai/pliegocss/actions/workflows/adapter-coexistence.yml" + } + ] }, { - "id": "website-current-source", - "status": "passed", - "evidenceClass": "measured", + "id": "registry-replay-refresh", + "dimension": "technical", + "status": "pending", + "evidenceClass": "inherited", "requiredForPromotion": true, - "summary": "The 90-route bilingual PliegoRS site passes deterministic byte-for-byte rebuild, ledger and corpus integrity, gzip budgets, English/Spanish route and legal parity, generated image delivery, desktop/mobile interaction, command search, reduced motion, WebGL, and horizontal-overflow contracts in Chrome 150 on the current working source." + "summary": "RC.2 was previously replayed from crates.io, but schema 3 requires a fresh, unexpired, artifact-hashed replay for the exact promotion candidate.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "pending-clean-host", + "arch": "x86_64", + "rust": "1.85.0", + "node": null, + "browser": null + }, + "requiredCoverage": ["nineteen-crates", "cli-install", "lsp-install", "downstream-public-api"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [ + { + "kind": "crates-io-release", + "url": "https://crates.io/crates/pliego-cssc/0.1.0-rc.2" + } + ] }, { - "id": "cloudflare-deployment-contract", - "status": "passed", - "evidenceClass": "measured", + "id": "production-deployment-refresh", + "dimension": "operational", + "status": "pending", + "evidenceClass": "inherited", "requiredForPromotion": true, - "summary": "Cloudflare Worker pliegocss-site version c3f34e3d-43be-4f87-b07a-3cc9f946df4f serves the final registry-replay status at https://pliegocss.dev through an enabled custom domain, Worker-managed apex DNS, and active Google Trust Services certificates. Production edge and Browser Rendering replay pass English, Spanish, documentation, legal, playground, security.txt, 404, CSP/security headers, immutable-asset caching, WebGL canvas, accessibility tree, and the RC.2 public-preview masthead." + "summary": "The production site has historical deployment evidence, but a current exact-source edge and browser replay with hashed artifacts is missing.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "cloudflare-workers", + "arch": "edge", + "rust": null, + "node": null, + "browser": "cloudflare-browser-rendering" + }, + "requiredCoverage": ["apex", "security-headers", "immutable-assets", "browser-rendering"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [ + { + "kind": "production-url", + "url": "https://pliegocss.dev/" + } + ] }, { - "id": "hosted-cross-os-current-source", - "status": "passed", - "evidenceClass": "measured", + "id": "signed-binary-distribution", + "dimension": "operational", + "status": "blocked", + "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "GitHub Actions run 29857699284 passes all twelve CI jobs at 42e0c2230967b322a989fd550ebd289f34380338 across Ubuntu, Windows, and macOS with Rust 1.85 and 1.96; CodeQL run 29857699053 passes JavaScript/TypeScript, Rust, and Actions analysis." + "summary": "RC.2 predates the repository-only G5 distribution contract. It has no exact-source native archives, checksums, CycloneDX SBOMs, GitHub/Sigstore attestations, immutable GitHub Release, or verified pnpm package asset; npmjs publication is forbidden.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "windows-linux-macos", + "arch": "x86_64-arm64", + "rust": null, + "node": null, + "browser": null + }, + "requiredCoverage": ["windows-x86_64", "linux-x86_64", "macos-arm64", "checksums", "cyclonedx-sbom", "github-sigstore-attestations", "repository-release-assets", "immutable-release", "pnpm-package-install", "no-npmjs"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [] }, { - "id": "registry-replay", - "status": "passed", - "evidenceClass": "measured", + "id": "external-adoption-evidence", + "dimension": "adoption", + "status": "pending", + "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "All nineteen exact-version 0.1.0-rc.2 crates are visible on crates.io. Rust 1.85 installs pliego-cssc and pliego-css-lsp with --locked, resolves the registry-only downstream graph without path or patch overrides, checks all targets and features, and runs the public API fixture." + "summary": "RC.2 predates the G7 authority. Recruitment is open, but the required external interviews, real incidents, and pilots have not been recorded as consented, redacted, reviewed, hash-bound evidence.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "external-research", + "arch": null, + "rust": null, + "node": null, + "browser": null + }, + "requiredCoverage": ["ten-to-fifteen-interviews", "twenty-incidents", "three-to-five-pilots", "three-independent-projects", "three-independent-organizations", "frozen-adapter-support-policy"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [] }, { "id": "final-promotion-authorization", + "dimension": "authorization", "status": "not-authorized", "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "Creating or publishing 0.1.0 requires explicit owner approval after all technical checks pass." + "summary": "Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree.", + "source": { + "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", + "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" + }, + "observedAt": "2026-07-22T14:10:00Z", + "expiresAt": "2026-07-29T14:10:00Z", + "environment": { + "os": "owner-decision", + "arch": null, + "rust": null, + "node": null, + "browser": null + }, + "requiredCoverage": ["explicit-owner-authorization"], + "coveredCoverage": [], + "waiverAdr": null, + "artifacts": [], + "references": [] } ], - "promotionRule": "0.1.0 may be created only when every requiredForPromotion check is passed on the exact source commit and the owner explicitly authorizes publication." + "promotionRule": "Promotion requires every required check to pass on this exact source commit and Git tree with unexpired complete coverage, hashed artifacts, no implicit waiver, all four readiness dimensions true, and explicit owner authorization." } diff --git a/docs/product/release-readiness-0.1.0.md b/docs/product/release-readiness-0.1.0.md index bdf484d..49b9286 100644 --- a/docs/product/release-readiness-0.1.0.md +++ b/docs/product/release-readiness-0.1.0.md @@ -1,82 +1,168 @@ # PliegoCSS 0.1.0 release readiness -Status: **hardening in progress; final `0.1.0` promotion blocked** - -The machine authority is -[`release-readiness-0.1.0.json`](./release-readiness-0.1.0.json). Its second -schema records each claim as **measured**, **inherited**, **pending**, or -**uncertain** and derives the overall result from checks required for -promotion. It no longer treats one obsolete release snapshot as a permanent -success condition. - -## Current facts - -- The canonical GitHub repository is public and its About panel points to - `https://pliegocss.dev` with a searchable CSS, Rust, compiler, migration, - token, LSP, WebAssembly, and static-analysis topic set. Public visibility - does not by itself authorize promoting final `0.1.0`. -- The historical `v0.1.0-rc.1` tag and GitHub prerelease exist. That immutable - tag points to an earlier commit, so this corrected source advances to - `0.1.0-rc.2` rather than moving it. -- Hosted run - [29767855672](https://github.com/celiumsai/pliegocss/actions/runs/29767855672) - passed the Rust matrix on Windows, Ubuntu, and macOS at `aabf2b4`. -- That run is inherited evidence only: it predates the current corrections and - skipped both media-query and historical-evidence checks. -- Focused tests for the confirmed audit findings pass locally. -- Gate A, Gate B, and paired Rust MSRV evidence were regenerated from reachable - clean commit `d16fe5d`, hash-allowlisted, and verified against the exact Git - harness and fixture blobs. -- The automated brand contract passes for the brandbook, symbols, lockups, - raster dimensions, font licenses, token projection, six reviewed GPT Image 2 - masters, their AVIF/WebP derivatives, canonical prompt specifications, and - generation manifest. -- Every fast-profile constituent passes on the current working source. Windows - runs formatting, `cargo check`, and the JavaScript documentation/product - gates; WSL runs the complete locked workspace tests, doctests, and Clippy - with warnings denied. Local Windows Application Control error `4551` still - prevents one-machine replay of newly linked test binaries, so fresh hosted - Windows evidence remains independently required. -- The current PliegoRS integration passes in WSL against a remote-reachable - revision and registry-replayable fixture locks. -- The expanded PliegoRS-authored public-preview website now has 90 - English/Spanish routes, legal and accessibility pages, reviewed brand - imagery, and the ordered masthead/footer contract. It passes deterministic - byte-for-byte rebuild, ledger and compiler-corpus integrity, gzip budgets, - language and legal parity, generated-image delivery, computed styles, - WebGL, playground, keyboard tabs, reduced motion, operations, command search, - mobile layout, and horizontal-overflow checks in Chrome 150 on the current - working source. -- The Cloudflare Workers Static Assets profile passes a Wrangler 4.110.0 dry - run, local edge-response replay, and production replay. Worker - `pliegocss-site` version `c3f34e3d-43be-4f87-b07a-3cc9f946df4f` is deployed - at `https://pliegocss.dev` through an enabled custom domain, Worker-managed - apex DNS, and active Google Trust Services certificates. English, Spanish, - documentation, legal, playground, security.txt, 404, CSP/security-header, - immutable-asset cache, WebGL canvas, accessibility-tree, and RC.2 masthead - contracts all pass against the production edge and Cloudflare Browser - Rendering. -- All nineteen exact-version `0.1.0-rc.2` crates are visible on crates.io. - Rust 1.85 installs `pliego-cssc` and `pliego-css-lsp` with `--locked`, resolves - the downstream fixture exclusively from the crates.io registry, checks every - target and feature, and executes the public API surface without local path or - patch overrides. -- GitHub Actions run - [29857699284](https://github.com/celiumsai/pliegocss/actions/runs/29857699284) - passes the twelve-job Ubuntu, Windows, and macOS matrix at `42e0c22`; - [CodeQL run 29857699053](https://github.com/celiumsai/pliegocss/actions/runs/29857699053) - passes JavaScript/TypeScript, Rust, and Actions analysis on the same source. -- Final `0.1.0` promotion remains separately blocked and requires a new, - explicit owner authorization. - -## Remaining promotion work - -1. merge the reviewed RC.2 source only after the protected branch checks pass; -2. retain a green CI and CodeQL matrix on the exact merged `main` commit; -3. create the immutable `v0.1.0-rc.2` tag and GitHub prerelease from that - verified commit; and -4. keep final `0.1.0` promotion blocked until it receives separate explicit - approval. - -A local green gate never substitutes for hosted evidence, registry replay, or -release authorization. + + +Status: **blocked** + +This page is a deterministic projection of the schema-3 +[`release-readiness-0.1.0.json`](./release-readiness-0.1.0.json) machine authority. +Historical reports and release notes do not change this state. + +## Candidate identity + +| Field | Value | +| --- | --- | +| Target | `0.1.0` | +| Candidate | `0.1.0-rc.2` | +| Tag | `v0.1.0-rc.2` | +| Source commit | `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` | +| Git tree | `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` | +| Observed | `2026-07-22T14:10:00Z` | +| Expires | `2026-07-29T14:10:00Z` | + +## Readiness dimensions + +| Dimension | Ready | +| --- | --- | +| Technical | no | +| Operational | no | +| Adoption | no | +| Authorized | no | +| Promotion | no | + +## Required checks + +| Check | Dimension | Status | Evidence | Coverage | Waiver | +| --- | --- | --- | --- | ---: | --- | +| `exact-candidate-ci` | technical | **pending** | inherited | 9/9 | none | +| `exact-candidate-codeql` | technical | **pending** | inherited | 3/3 | none | +| `g0-corrections-on-candidate` | technical | **pending** | pending | 0/4 | none | +| `browser-release-matrix` | technical | **blocked** | pending | 0/5 | none | +| `adapter-coexistence-matrix` | technical | **blocked** | pending | 0/13 | none | +| `registry-replay-refresh` | technical | **pending** | inherited | 0/4 | none | +| `production-deployment-refresh` | operational | **pending** | inherited | 0/4 | none | +| `signed-binary-distribution` | operational | **blocked** | pending | 0/10 | none | +| `external-adoption-evidence` | adoption | **pending** | pending | 0/6 | none | +| `final-promotion-authorization` | authorization | **not-authorized** | pending | 0/1 | none | + +## Evidence and blockers + +### exact-candidate-ci + +GitHub Actions run 29864046865 passed the exact RC.2 commit across Ubuntu, Windows, and macOS with Rust 1.85 and 1.96 plus the listed release jobs, but its promotion window has expired and requires a fresh candidate replay. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:05:00Z` → `2026-07-29T14:05:00Z` +- Required coverage: `ubuntu-rust-1.85`, `ubuntu-rust-1.96`, `windows-rust-1.85`, `windows-rust-1.96`, `macos-rust-1.85`, `macos-rust-1.96`, `package-archives`, `supply-chain`, `wasm-diagnostics` +- Covered: `ubuntu-rust-1.85`, `ubuntu-rust-1.96`, `windows-rust-1.85`, `windows-rust-1.96`, `macos-rust-1.85`, `macos-rust-1.96`, `package-archives`, `supply-chain`, `wasm-diagnostics` +- Hashed artifacts: + - [docs/product/evidence/v0.1.0-rc.2-source.json](https://github.com/celiumsai/pliegocss/blob/main/docs/product/evidence/v0.1.0-rc.2-source.json) — `sha256:51afb97c3b86fbd34ad7d827e7fc5347f1e8d85e5dd1456e44d0c9389a82fecb` +- References: + - [github-actions-run](https://github.com/celiumsai/pliegocss/actions/runs/29864046865) + +### exact-candidate-codeql + +CodeQL run 29864046876 passed Actions, JavaScript/TypeScript, and Rust analysis on the exact RC.2 commit, but its promotion window has expired and requires a fresh candidate replay. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:05:00Z` → `2026-07-29T14:05:00Z` +- Required coverage: `actions`, `javascript-typescript`, `rust` +- Covered: `actions`, `javascript-typescript`, `rust` +- Hashed artifacts: + - [docs/product/evidence/v0.1.0-rc.2-source.json](https://github.com/celiumsai/pliegocss/blob/main/docs/product/evidence/v0.1.0-rc.2-source.json) — `sha256:51afb97c3b86fbd34ad7d827e7fc5347f1e8d85e5dd1456e44d0c9389a82fecb` +- References: + - [github-actions-run](https://github.com/celiumsai/pliegocss/actions/runs/29864046876) + +### g0-corrections-on-candidate + +G0 corrections are not part of the exact RC.2 candidate. They require a newly named candidate plus hosted replay before they can become release evidence. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `breakpoints-52-72`, `migration-empty`, `css-rule-walker`, `toolchain-isolation` +- Covered: none +- Hashed artifacts: none + +### browser-release-matrix + +Every required hosted browser lane is not configured for the exact candidate source; local or historical browser runs have no promotion authority. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `windows-chrome`, `windows-edge`, `windows-firefox`, `linux-chromium`, `macos-safari` +- Covered: none +- Hashed artifacts: none +- References: + - [current-matrix](https://github.com/celiumsai/pliegocss/blob/main/docs/benchmarks/hosted-browser-matrix.json) + +### adapter-coexistence-matrix + +RC.2 predates the G6 adapter authority. It has no clean exact-source 21-project HTML/Vite/PliegoRS matrix, Tailwind v3/v4 source/output audit, DOM/ARIA/style equivalence, exact rollback, or pinned PliegoRS framework replay. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `twenty-one-projects`, `html-seven`, `vite-seven`, `pliegors-seven`, `tailwind-v3-lts`, `tailwind-v4-current`, `tailwind-source-output-audit`, `dom-aria-style-geometry`, `exact-rollback`, `windows-x64`, `linux-x64`, `macos-arm64`, `pinned-pliegors-framework-browser` +- Covered: none +- Hashed artifacts: none +- References: + - [adapter-coexistence-workflow](https://github.com/celiumsai/pliegocss/actions/workflows/adapter-coexistence.yml) + +### registry-replay-refresh + +RC.2 was previously replayed from crates.io, but schema 3 requires a fresh, unexpired, artifact-hashed replay for the exact promotion candidate. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `nineteen-crates`, `cli-install`, `lsp-install`, `downstream-public-api` +- Covered: none +- Hashed artifacts: none +- References: + - [crates-io-release](https://crates.io/crates/pliego-cssc/0.1.0-rc.2) + +### production-deployment-refresh + +The production site has historical deployment evidence, but a current exact-source edge and browser replay with hashed artifacts is missing. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `apex`, `security-headers`, `immutable-assets`, `browser-rendering` +- Covered: none +- Hashed artifacts: none +- References: + - [production-url](https://pliegocss.dev/) + +### signed-binary-distribution + +RC.2 predates the repository-only G5 distribution contract. It has no exact-source native archives, checksums, CycloneDX SBOMs, GitHub/Sigstore attestations, immutable GitHub Release, or verified pnpm package asset; npmjs publication is forbidden. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `windows-x86_64`, `linux-x86_64`, `macos-arm64`, `checksums`, `cyclonedx-sbom`, `github-sigstore-attestations`, `repository-release-assets`, `immutable-release`, `pnpm-package-install`, `no-npmjs` +- Covered: none +- Hashed artifacts: none + +### external-adoption-evidence + +RC.2 predates the G7 authority. Recruitment is open, but the required external interviews, real incidents, and pilots have not been recorded as consented, redacted, reviewed, hash-bound evidence. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `ten-to-fifteen-interviews`, `twenty-incidents`, `three-to-five-pilots`, `three-independent-projects`, `three-independent-organizations`, `frozen-adapter-support-policy` +- Covered: none +- Hashed artifacts: none + +### final-promotion-authorization + +Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree. + +- Source: `064dcbce96a3a5cc97a940d07566c008bb5e2d3e` / tree `010a64f5d0bbd142a1d015fda4ccf573cc6e4133` +- Validity: `2026-07-22T14:10:00Z` → `2026-07-29T14:10:00Z` +- Required coverage: `explicit-owner-authorization` +- Covered: none +- Hashed artifacts: none + +## Promotion rule + +Promotion requires every required check to pass on this exact source commit and Git tree with unexpired complete coverage, hashed artifacts, no implicit waiver, all four readiness dimensions true, and explicit owner authorization. + +Archived snapshots are indexed under [`../archive/`](../archive/README.md). diff --git a/docs/product/representative-applications-gap-map.json b/docs/product/representative-applications-gap-map.json index 5f9a907..a84a497 100644 --- a/docs/product/representative-applications-gap-map.json +++ b/docs/product/representative-applications-gap-map.json @@ -1,20 +1,20 @@ { "schemaVersion": 1, - "statusDate": "2026-07-19", + "statusDate": "2026-07-25", "applications": [ { "id": "plain-html-audit-first", "gate": "integration:representative:plain-html-audit", "status": "passed", "proven": ["ordinary CSS adoption", "budgets", "static accessibility", "audit control receipt"], - "gaps": ["generic CSS transform/minify output", "complete CSS classification", "browser visual evidence"] + "gaps": ["complete CSS classification", "external adoption evidence"] }, { "id": "vite-tailwind-inventory-first", "gate": "integration:representative:vite-tailwind", "status": "passed", - "proven": ["bounded directory discovery", "Tailwind source/template inventory", "Preflight reliance"], - "gaps": ["reversible Tailwind migration", "semantic output parity", "visual evidence"] + "proven": ["bounded directory discovery", "Tailwind source/template inventory", "Preflight reliance", "static Tailwind v3/v4 coexistence", "exact rollback", "browser DOM/ARIA/style equivalence"], + "gaps": ["dynamic template migration", "arbitrary plugin/config execution", "external adoption evidence"] }, { "id": "css-modules-consumer", @@ -34,46 +34,46 @@ "id": "framework-neutral-multiroute", "gate": "integration:representative:framework-routes", "status": "passed", - "proven": ["route/island Asset Plan", "Project Index", "schema-5 physical trace", "controlled bundle group"], - "gaps": ["configured PliegoRS checkout", "browser replay", "hosted adapter evidence"] + "proven": ["route/island Asset Plan", "Project Index", "schema-5 physical trace", "controlled bundle group", "pinned PliegoRS SSG/browser replay"], + "gaps": ["multiple external PliegoRS applications", "published adapter compatibility contract"] } ], "priorities": [ { "rank": 1, - "id": "standard-css-transform", + "id": "external-pilot-evidence", "releaseImpact": "blocking", - "exposedBy": ["plain-html-audit-first"], - "reason": "The first-value audit path works, but an ordinary CSS application cannot yet opt into deterministic target transform/minify output and bind it into the same evidence graph." + "exposedBy": ["plain-html-audit-first", "vite-tailwind-inventory-first", "framework-neutral-multiroute"], + "reason": "The schema-1 G7 authority and 0.1.x adapter policy are frozen and recruitment is open in GitHub issue #7, but the reviewed ledgers contain zero external records. LTS promotion still needs independent projects and maintainers to validate adoption, rollback, and integration behavior." }, { "rank": 2, - "id": "classification-corpus", + "id": "published-rc-replay", "releaseImpact": "blocking", - "exposedBy": ["plain-html-audit-first", "vite-tailwind-inventory-first"], - "reason": "The compatibility coverage warning remains honest but prevents complete supported-CSS claims." + "exposedBy": ["rust-typed-controlled-artifacts", "framework-neutral-multiroute"], + "reason": "Checkout and repository-shaped evidence do not activate installation or SemVer promises; an immutable exact-source RC must replay the selected adapters." }, { "rank": 3, - "id": "hosted-browser-os-evidence", + "id": "classification-corpus", "releaseImpact": "blocking", - "exposedBy": ["plain-html-audit-first", "framework-neutral-multiroute"], - "reason": "Local static and framework-neutral evidence cannot substitute for Chromium, Firefox, WebKit, and supported-OS runs." + "exposedBy": ["plain-html-audit-first", "vite-tailwind-inventory-first"], + "reason": "The compatibility coverage warning remains honest and still prevents a complete supported-CSS claim." }, { "rank": 4, - "id": "reversible-migration-slice", + "id": "dynamic-tailwind-seams", "releaseImpact": "should", - "exposedBy": ["vite-tailwind-inventory-first", "css-modules-consumer"], - "reason": "Inventory finds exact seams, but adoption still lacks one conservative, reviewable, reversible conversion with visual proof." + "exposedBy": ["vite-tailwind-inventory-first"], + "reason": "G6 intentionally rejects dynamic class construction and arbitrary plugin/config execution; any broader adapter must remain a separate fail-closed versioned contract." }, { "rank": 5, - "id": "published-rc-replay", - "releaseImpact": "blocking", - "exposedBy": ["rust-typed-controlled-artifacts", "framework-neutral-multiroute"], - "reason": "Checkout and registry-shaped evidence do not activate installation or SemVer promises." + "id": "css-modules-reversible-migration", + "releaseImpact": "should", + "exposedBy": ["css-modules-consumer"], + "reason": "CSS Modules inventory is exact, but G6 promotes only the bounded Tailwind coexistence slice; module export and bundler semantics still need their own reversible protocol." } ], - "selectedNext": "standard-css-transform" + "selectedNext": "external-pilot-evidence" } diff --git a/docs/product/representative-applications-gap-map.md b/docs/product/representative-applications-gap-map.md index 4490107..fc4afdf 100644 --- a/docs/product/representative-applications-gap-map.md +++ b/docs/product/representative-applications-gap-map.md @@ -1,34 +1,37 @@ # Representative applications and selected gaps -Status: **five application gates complete; first evidence-driven gap selected** +Status: **five owned application gates complete; external pilot evidence remains selected** -The machine authority is [`representative-applications-gap-map.json`](./representative-applications-gap-map.json). -The applications deliberately cover distinct adoption modes rather than five variants of the typed -utility fixture. +The machine authority is +[`representative-applications-gap-map.json`](./representative-applications-gap-map.json). Owned +applications establish bounded product behavior; they are not adoption evidence. -| Application | Gate | Proven boundary | Main exposed gap | -|---|---|---|---| -| Plain HTML audit-first | `integration:representative:plain-html-audit` | Ordinary CSS, budgets, static accessibility, receipt | No deterministic standard-CSS transform/minify output | -| Vite/Tailwind inventory-first | `integration:representative:vite-tailwind` | Read-only source/template inventory and Preflight reliance | No reversible semantic migration | -| CSS Modules consumer | `integration:representative:css-modules` | Sources, imports, aliases, destructuring, dynamic usage, composition | No export/bundler proof or reversible migration | -| Typed Rust controlled build | `integration:representative:rust-control` | Rust 1.85 API, `pc`/`pcx`, seven controlled artifacts | Clean-build and rendered-app evidence | -| Framework-neutral routes | `integration:representative:framework-routes` | Routes/island, Asset Plan, Project Index, physical trace | PliegoRS/browser/hosted evidence not configured | +| Application | Proven boundary | Remaining gap | +|---|---|---| +| Plain HTML audit-first | Ordinary CSS adoption, budgets, static accessibility, audit receipt | Complete CSS classification and external adoption | +| Vite/Tailwind inventory-first | Tailwind source/template inventory, v3/v4 coexistence, exact rollback, browser equivalence | Dynamic templates/config/plugins and external adoption | +| CSS Modules consumer | Source, consumer-use, and composition inventory | Export/bundler proof and reversible CSS Modules migration | +| Typed Rust controlled build | Rust 1.85 API and controlled artifacts | Registry replay and clean external consumption | +| Framework-neutral routes | Asset Plan, Project Index, physical trace, pinned PliegoRS browser replay | Multiple external PliegoRS applications | ## Priority order -1. **Standard-CSS transform/minify output** — release blocking and directly exposed by the first-value - plain HTML application. -2. **Supported classification corpus** — release blocking; the coverage warning remains honest. -3. **Hosted browser/OS evidence** — release blocking and cannot be manufactured locally. -4. **One reversible migration slice** — report-level Should, selected from Tailwind or CSS Modules. -5. **Published RC replay** — release blocking and owner-dependent at publication time. - -The first implementation gap is therefore a bounded deterministic output path for ordinary CSS. It -must reuse the existing Lightning CSS boundary, preserve audit findings and target policy, keep input -read-only, and bind generated output into explicit artifacts rather than changing `audit` silently. - -## Evidence boundary - -Four applications are fully local. The fifth proves the neutral adapter contract because the sibling -PliegoRS checkout was absent. Its report records `not-configured` and `not-run`; no PliegoRS or browser -success is inferred. External environment gates remain separate. +1. **External pilot evidence** — release blocking. The authority and frozen support policy exist, but + the reviewed ledgers currently contain zero interviews, incidents, or pilots. Recruitment is + open through [GitHub issue #7](https://github.com/celiumsai/pliegocss/issues/7); applications do + not count as evidence. +2. **Published RC replay** — release blocking. A repository checkout does not activate installation + or SemVer promises. +3. **Classification corpus** — release blocking for broader standard-CSS claims. +4. **Dynamic Tailwind seams** — intentionally outside schema 1 and not part of the 0.1.x claim. +5. **CSS Modules reversible migration** — useful but outside the promoted G6 slice. + +## G7 boundary + +G7 requires 10–15 consented external interviews, 20 real incidents, and 3–5 completed pilots across +at least three independent projects and organizations. The +[machine-counted status](./external-adoption-g7.md) rejects Celiums/PliegoCSS-owned projects, +synthetic incidents, duplicate identities, missing consent, missing review, and unhashed source +material. + +The selected next gap remains `external-pilot-evidence` until real participants satisfy it. diff --git a/docs/product/research-requirements.md b/docs/product/research-requirements.md index 267f0e4..412ea2f 100644 --- a/docs/product/research-requirements.md +++ b/docs/product/research-requirements.md @@ -39,14 +39,14 @@ not the required input or product category. Its durable constraints are: | A2 Property/token model | Verified for the current alpha catalog | `docs/status/f2.md`; descriptor-driven property contract; typed domains for layout, spacing, color, typography, borders, and effects | Freeze the catalog/version only at the release candidate; DTCG interoperability is A7. | | A3 Variants/scopes | Verified for the explicit 0.1.0 scope | `docs/status/f4.md`; responsive and typed container breakpoints, interaction, theme, motion, contrast, typed boolean and configurable ARIA/data-state/presence/value variants, `ltr`/`rtl`, typed native writing modes, fixed-order typed cascade layers, arbitrary selector subset, and conditional composition; ADR-0014 defers native component scope because the frozen browser vector predates `@scope` and no selector fallback is equivalent | Post-0.1 component scope and broader capability tiers require a new reviewed policy decision. | | A4 Canonicalization/hash | Partial | StyleId format 2, semantic-IR binary format 2, property invariance, container-condition frozen vector, 40-process determinism gate | Clean cross-machine equivalence on the release commit and explicit authored-order equivalence corpus across every new IR dimension. | -| A5 Native CSS emitter | Partial | `docs/status/f3.md`; Lightning CSS parse/transform boundary; minified/pretty output; native `@container` and fixed-order `@layer` emission; schema-5 physical nesting/order trace; local Chromium computed-style smoke; ADR-0014 explicitly moves native component scope beyond 0.1.0 | W3C-valid corpus, Firefox/WebKit matrix, and broader compatibility-policy fixtures. | +| A5 Native CSS emitter | Partial | `docs/status/f3.md`; Lightning CSS parse/transform boundary; minified/pretty output; native `@container` and fixed-order `@layer` emission; schema-5 physical nesting/order trace; Browser/output certification v1 compares 52 computed properties, 1/64-pixel box/text-line geometry bounded to 0.1 CSS px, and bounded PNGs against current Tailwind across responsive/hover/focus and explicit reset modes; dirty local Windows x64 and Linux x64 Chromium/Firefox/WebKit diagnostics pass; ADR-0014 explicitly moves native component scope beyond 0.1.0 | Produce the clean hosted 9/9 Windows x64, Linux x64, and macOS ARM64 artifact; add a W3C-valid corpus and broader compatibility-policy fixtures. | | A6 Manifest/source maps | Partial | Manifest schemas 3–5, graph schemas 1–2, exact UTF-8 spans/ranges, Project Index schema 1, canonical integrity-bound Source Map v3 for generated control groups, a revision-pinned PliegoRS SSG consumer validating exact source snapshots through bundle-qualified physical output, and a plain-HTML fixture binding exact line-input origins to manifest classes | Make browser-devtools and LSP integrations consume the shared artifacts, make the future non-Rust framework adapter emit Project Index/topology rather than only schema-3 style facts, and add a release-commit compatibility vector. | | A7 DTCG token bridge | Partial | `pliego-css-config` implements the stable DTCG 2025.10 format bridge, bounded same-document Resolver sets/modifiers/contexts, canonical aliases/derived values, cycle rejection, metadata/deprecation provenance, validated theme permutations, and `pliegocss-token-graph/1`; controlled direct CLI builds bind exact Resolver bytes, canonical selections, and the complete integrity-bound graph; bundle-plan schema 2 integrates the same contract with local Windows/WSL E2E and package gates green; Cargo `theme!` selects the same active registry for macro identity, with local full-workspace/MSRV/API and clean-package gates green; accessibility policy schema 1 declares contrast pairs and resolves token endpoints through an optional canonical graph | Decide the optional external-reference policy if justified, complete supported-type validation for preserved namespaces, and reproduce graph/policy evidence on hosted release machines. | | A8 Compatibility policy | Verified for the alpha catalog and bounded standard-CSS rule classifier | Policy schema 2 / policy 7; exact `web-features@3.32.0` and `baseline-browser-mapping@2.10.43` identities/hashes/query; frozen `baseline-widely`, fixed `modern`, unmanaged `none`; AST-backed audit decisions for layers, container query kinds, nesting, `@property`, `@scope`, and `@starting-style`; CMP001–CMP003 and unknown at-rules fail closed in strict mode; explicit partial-coverage finding; `pnpm check:compatibility`; ADR-0012/0013/0014 | Complete declaration-value and selector classification plus hosted Chrome/Firefox/WebKit execution remain A5/A12 evidence; component scope is explicitly `error` and deferred beyond 0.1.0, not silently supported. | | A9 Host adapters | Partial | Standard class conversion; framework-neutral typed topology collector with fail-closed Rust inventory/site ownership; revision-pinned PliegoRS product registry and SSR/SSG fixture generating reachability plus shared/route/island/unreachable partitions and consuming Asset Plan/Project Index; rustc dep-info completeness across the exact native library, SSG binary, and wasm32 client targets; framework-neutral manifests; and a local Chrome plain-HTML contract fixture that maps exact line-oriented manifest origins to explicit template slots with zero styling runtime | Extend Cargo completeness evidence when feature/target support expands, and add at least one typed Vite/React or Astro adapter producing identical IR/artifacts and exact dynamic-branch coverage. | -| A10 Tailwind analyzer | Partial | Tailwind 4.3.2 benchmark remains pinned; migration project schema 1 binds exact Tailwind CSS, config, plugin, and template files, resolves typed relative seams, inventories literal/dynamic template candidates plus closed unsupported config/plugin hooks, and has both an authored contract runner and a pinned MIT public-project role corpus without executing JavaScript. The public corpus measures the complete cross-tool role surface at 104 TP, 0 FP, and 0 FN over 119 reviewed files. | Add broader template/arbitrary-value/interpolation and source/safelist graphs, conflicts, semantic-observation gold labels, migration outcomes, and a larger representative corpus. | +| A10 Tailwind analyzer | Partial | Benchmark Authority v2 pins live 4.3.3, upstream v3-LTS 3.4.19, and historical 4.3.2 lanes; migration project schema 1 binds exact Tailwind CSS, config, plugin, and template files, resolves typed relative seams, inventories literal/dynamic template candidates plus closed unsupported config/plugin hooks, and has both an authored contract runner and a pinned MIT public-project role corpus without executing JavaScript. The public corpus measures the complete cross-tool role surface at 104 TP, 0 FP, and 0 FN over 119 reviewed files. | Add broader template/arbitrary-value/interpolation and source/safelist graphs, conflicts, semantic-observation gold labels, migration outcomes, and a larger representative corpus. | | A11 Language tooling | Partial | Machine-readable diagnostics, catalog, explain, formatting replacements, manifests, shared Project Index schemas 1/2, an initial standard stdio LSP with full buffer sync/UTF-16/completion/hover/diagnostics/formatting plus integrity-verified source-to-final-CSS definition links, bounded cached per-literal reuse of compiler diagnostic schema 1, an unreleased VS Code client candidate with explicit executable/index configuration and a VSIX gate, and a pinned real VS Code 1.105.1 extension-host diagnostic/definition gate | Add another editor client, add debounce/cancellation, cover cross-literal `pcx!` semantics, and prove complete equality between CLI/editor errors over a frozen negative corpus and hosted matrix. | -| A12 Performance corpus | Partial | Gate A/B, Rust-check, watch, pruning, media merge, fuzz, parallel determinism, package-size gates | Content-addressed 1k/10k/100k graphs, 10-package monorepo, 50/500 scoped-file fixtures, cold/warm/incremental memory and p50/p95, hosted release-commit evidence. | +| A12 Performance corpus | Partial | Benchmark Authority v2 micro/medium/large paired Tailwind lanes; Gate A/B historical context; Rust-check, watch, pruning, media merge, fuzz, parallel determinism, package-size gates; Browser/output certification v1 host evidence schema and 3×3 workflow | Content-addressed 1k/10k/100k graphs, 10-package monorepo, 50/500 scoped-file fixtures, cold/warm/incremental memory and p50/p95, hosted release-commit benchmark plus browser evidence. | ## Cross-cutting requirements not represented by one gate diff --git a/docs/reference/adapter-coexistence.md b/docs/reference/adapter-coexistence.md new file mode 100644 index 0000000..c88f6c5 --- /dev/null +++ b/docs/reference/adapter-coexistence.md @@ -0,0 +1,72 @@ +# Adapter coexistence and bounded Tailwind migration + +Status: **G6 bounded contract** + +PliegoCSS supports a conservative migration slice for literal, complete utility groups in plain +HTML, Vite production output, and PliegoRS rendered HTML. It is designed for coexistence: Tailwind +stays loaded while selected markup switches to compiler-generated `pc_` classes and a PliegoCSS +sidecar. + +## Architecture + +```mermaid +flowchart LR + S["Static project snapshot"] --> T["Tailwind v3 or v4 compile"] + S --> C["PliegoCSS compile"] + T --> A["Standard CSS audit + control receipt"] + C --> M["Manifest-authorized class mapping"] + M --> G["Explicit migration group"] + G --> P["Apply exact template + sidecar bytes"] + P --> B["Browser DOM / ARIA / style / geometry replay"] + B --> R["Exact rollback"] +``` + +The compiler manifest, not a regex-derived utility guess, authorizes each source-group-to-class +mapping. The adapter accepts only canonical double-quoted `class` attributes. `className`, Vue +bindings, interpolation, embedded scripts/styles, and non-literal groups are rejected by schema 1. + +## What coexistence means + +Before apply, the document loads Tailwind CSS and an empty `pliego.css`. The approved group changes +only the complete class values and the Pliego sidecar bytes. Tailwind remains byte-identical and +loaded after apply. That lets teams migrate bounded templates or routes without making removal of +Tailwind part of the same irreversible action. + +Rollback checks the current after hashes, restores the exact before bytes, and removes the receipt. +Drift after apply blocks rollback rather than overwriting user changes. + +## Tailwind output audit + +Each project uses one pinned competitor profile: + +- `tailwindcss@3.4.19`, the upstream `v3-lts` lane, with `@tailwind utilities` and Preflight off; +- `tailwindcss@4.3.3`, the current lane at contract creation, using CSS imports and Preflight off. + +The emitted CSS passes ordinary standard-CSS audit twice: publish the control group, then recompute +it in `--check` mode. Warnings remain visible in evidence; any error blocks the project. + +## Evidence boundary + +The tracked corpus contains 21 unique project documents: seven HTML, seven Vite, and seven PliegoRS +rendered-output cases. Every project must pass DOM, ARIA, computed-style, layout-geometry, apply, and +rollback checks. Vite cases also run `vite build`. The hosted matrix replays all 21 on Windows, +Linux, and macOS Chromium, for 63 project/host replays, and requires the separate pinned PliegoRS +framework browser job. + +This does not prove arbitrary Tailwind compatibility, plugin execution, safe dynamic codemods, +Tailwind removal, or external adoption. Those seams fail closed or remain G7 work. + +## Commands + +```console +pnpm check:adapter-coexistence-authority +pnpm check:adapter-coexistence -- --browser=chromium +pnpm check:adapter-coexistence-matrix -- \ + --evidence-root=target/adapter-coexistence-certification/incoming \ + --pliegors-evidence=target/adapter-coexistence-certification/pliegors/pliegors-framework.json \ + --require-clean +``` + +The authority is +[`benchmarks/adapter-coexistence-v1/authority.json`](../../benchmarks/adapter-coexistence-v1/authority.json), +and the project snapshots are in its hash-bound `corpus.json`. diff --git a/docs/reference/adapter-support-policy-0.1.x.md b/docs/reference/adapter-support-policy-0.1.x.md new file mode 100644 index 0000000..e655329 --- /dev/null +++ b/docs/reference/adapter-support-policy-0.1.x.md @@ -0,0 +1,45 @@ +# Adapter support policy for 0.1.x + +Status: **frozen for 0.1 promotion** + +The machine-readable authority is +[`adapter-support-policy-0.1.x.json`](../product/adapter-support-policy-0.1.x.json). The policy is +deliberately narrower than Tailwind's complete feature surface: it publishes only what the G6 +coexistence protocol can reproduce, compare in a real browser, and roll back byte-for-byte. + +## Certified matrix + +| Input adapter | Certified version/input | Tailwind profile | +|---|---|---| +| Static HTML | Complete static HTML document | 3.4.19 or 4.3.3 | +| Vite | Vite 8.1.5 production build | 3.4.19 or 4.3.3 | +| PliegoRS | Rendered static HTML plus `pliego-dom=0.0.2` / `pliego-ssg=0.0.2` browser replay | 3.4.19 or 4.3.3 | + +Certified adoption operates on complete, literal class groups. Tailwind remains loaded without +Preflight during coexistence. Apply changes only the approved class-group bytes and PliegoCSS +sidecar; rollback restores the exact original source bytes. + +## Required promotion evidence + +Every promoted adapter/profile pair must retain: + +- clean Windows x64, Linux x64, and macOS arm64 Chromium lanes; +- DOM and ARIA equivalence; +- frozen computed-style equivalence; +- layout geometry within 0.1 CSS px; +- exact rollback; +- the separate pinned PliegoRS framework/browser replay when that adapter is claimed. + +## Explicit exclusions + +The certified tier does not include dynamic class construction, arbitrary Tailwind config or plugin +execution, unsupported utilities, Tailwind removal, or versions other than those listed above. +Reports outside the matrix may be triaged best-effort, but they cannot extend the 0.1.x compatibility +claim without a versioned policy change and new evidence. + +## Verification + +```console +pnpm check:adapter-coexistence-authority +pnpm check:external-adoption-authority +``` diff --git a/docs/reference/asset-plan-schema.md b/docs/reference/asset-plan-schema.md index c2d3793..3ce7dd6 100644 --- a/docs/reference/asset-plan-schema.md +++ b/docs/reference/asset-plan-schema.md @@ -60,7 +60,7 @@ files. "applicationCoverage": "adapter-attested-complete", "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, + "themeIdFormatVersion": 3, "themeId": "b3d5ad77175995c2b8f51ef7c0d41991", "targets": "modern", "format": "minified", diff --git a/docs/reference/bundle-plan.md b/docs/reference/bundle-plan.md index 1d3e3f2..a0bc375 100644 --- a/docs/reference/bundle-plan.md +++ b/docs/reference/bundle-plan.md @@ -168,7 +168,7 @@ Every bundle runs the normal source scan, semantic compilation, deterministic ca identity-stream ordering, optional theme emission, and Lightning CSS target/format pipeline. Each `.manifest.json` is an independent [schema-3 manifest](./cli.md#manifest-schema-3) by default, whose digest and byte count describe its -adjacent CSS file. Each manifest reports StyleId format 2, class-name format 1, and ThemeId format 2 +adjacent CSS file. Each manifest reports StyleId format 2, class-name format 1, and ThemeId format 3 in required top-level fields. This output-schema change does not alter the independently selected bundle-plan schema 1 or 2. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 65d3fff..771ca87 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -47,7 +47,7 @@ cargo run -p pliego-cssc -- compile --source src/lib.rs --theme --output pliego. Install the public-preview binary from crates.io and invoke it from an application workspace: ```console -cargo install pliego-cssc --version '=0.1.0-rc.2' --locked +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked pliego-cssc --version pliego-cssc check --source src ``` @@ -364,8 +364,8 @@ integrity mismatches. Every successful JSON document that exposes identity carries three required top-level fields: `styleIdFormatVersion`, `classNameFormatVersion`, and `themeIdFormatVersion`. Their current values -are 2, 1, and 1 respectively. They appear in manifest schemas 3, 4, and 5, inspection schema 2, catalog -schema 3, and explain schema 2. A consumer must check them before interpreting a `styleId`, class, or theme +are 2, 1, and 3 respectively. They appear in manifest schemas 3, 4, and 5, inspection schema 3, catalog +schema 4, and explain schema 2. A consumer must check them before interpreting a `styleId`, class, or theme identity. The schema-1 diagnostic envelope is unchanged and does not claim that identity resolution completed. @@ -1084,7 +1084,7 @@ pliego.manifest.json` writes pretty JSON with one trailing newline: "schemaVersion": 3, "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, + "themeIdFormatVersion": 3, "themeId": "b3d5ad77175995c2b8f51ef7c0d41991", "targets": "modern", "format": "minified", @@ -1203,9 +1203,9 @@ multi-file transactional or crash-atomic replacement. - Theme CSS currently emits the registry token kinds supported by the emitter's custom-property contract; `--prune-unreachable` does not filter that block. Other tokens still affect lowering and identity. -- Identity and class formats are explicitly versioned. The `0.1.0-rc.2` vectors are a +- Identity and class formats are explicitly versioned. The `0.1.0-rc.3` vectors are a published prerelease contract rather than a final `0.1.0` stability promise. See the [StyleId format-2 reference](./style-id-format-v2.md). -- The CLI may be installed from exact crates.io version `0.1.0-rc.2` or from an +- After publication, the CLI may be installed from exact crates.io version `0.1.0-rc.3` or from an exact checkout revision. Once installed, `pliego-cssc` is standalone and can run from an application directory; it does not require that application to belong to the PliegoCSS Cargo workspace. diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index ba69f8e..d995e5c 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -1,6 +1,6 @@ # Compatibility candidate contract -Status: **machine-enforced prerelease vectors at `0.1.0-rc.2`; final `0.1.0` promotion remains open** +Status: **machine-enforced prerelease vectors at `0.1.0-rc.3`; final `0.1.0` promotion remains open** PliegoCSS already persists identities and artifacts across the macro, build-script bridge, CLI, SSR, and CSS boundary. Those bytes cannot be allowed to drift accidentally while the broader 0.1 API is @@ -16,37 +16,37 @@ class identity, theme artifact, or manifest that another component does not unde Cargo manifests use local paths plus exact internal version requirements for this reason. Packaging removes the paths and retains the exact requirements. Public-preview consumers -must pin every direct PliegoCSS dependency to exact version `0.1.0-rc.2`. +must pin every direct PliegoCSS dependency to exact version `0.1.0-rc.3`. ## Enforced vectors | Surface | Version/vector | Enforced by | |---|---|---| | Representative StyleId vector | `STYLE_ID_FORMAT_VERSION = 2`; minimal-theme vector below | `v2_style_identity_and_class_are_frozen` | -| Minimal-theme style | `flex gap-gutter tablet:grid` → `e0b572e3fdfbf091d9a2ddb126278634` | compiler unit test | -| Class-name encoding | `CLASS_NAME_FORMAT_VERSION = 1`; same style → `pc_dax2y1pql4op1rjk97yv9e88k` | IR/compiler unit tests | +| Minimal-theme style | `flex gap-gutter tablet:grid` → `b742ceb589d4f412c6ba77e81f632f53` | compiler unit test | +| Class-name encoding | `CLASS_NAME_FORMAT_VERSION = 1`; same style → `pc_aukxxmkm8bmauj8duf8zpjdcj` | IR/compiler unit tests | | Full-width class boundary | `u128::MAX` → `pc_f5lxx1zz5pnorynqglhzmsp33` | IR unit test | -| Candidate seed ThemeId | `b3d5ad77175995c2b8f51ef7c0d41991` | seed/portability vectors | -| Candidate seed style | `flex gap-4` → `70cb04ef9bf9621f5826351f1778f68e` / `pc_6oe73ec16rbb7ublcoa3bpzf2` | compiler unit test | -| Theme identity stream | `THEME_ID_FORMAT_VERSION = 2`; SHA-256 truncated to its first 128 bits | `v2_theme_identity_stream_digest_and_id_are_frozen` | -| Theme binary | magic `PLGCTHM\0`, format 2; format 1 rejected explicitly | theme unit test | +| Candidate seed ThemeId | `b98b78da29201938d135b8bb94717788` | seed/portability vectors | +| Candidate seed style | `flex gap-4` → `fe3a92576be2bb53e3240249bc45b829` / `pc_f1u1l7d58kemkdqdjie56hdex` | compiler unit test | +| Theme identity stream | `THEME_ID_FORMAT_VERSION = 3`; SHA-256 truncated to its first 128 bits; breakpoint cascade rank is explicit | `v3_theme_identity_stream_digest_and_id_are_frozen` | +| Theme binary | magic `PLGCTHM\0`, format 3; older formats rejected explicitly | theme unit test | | Cargo theme bridge | legacy TOML `theme!(PATH)` plus DTCG `theme!(tokens = PATH, inputs = { "modifier" => "context" })`; one selected registry artifact per package | build unit tests, Debian WSL2 full-workspace/Rust 1.85 public-API smoke, Windows dirty-package extracted DTCG/TOML consumers, and clean package gate at `9714b09` | -| Semantic IR binary | magic `PLGCIR\0\0`, format 2; 309-byte golden SHA-256 `337865f8e7b5b32fe58f67442537ad1d2926d94164d1692863a2616dddc85023`; append-only container, writing-mode, and cascade-layer extensions; embeds StyleId format 2 and ThemeId format 2 | compiler golden/round-trip tests | -| Minimal theme fixture | ID `cf5c4c0674fd1c7e5121da0d27222ae0` | theme unit test | -| Minimal theme binary | format 2, 102 bytes, SHA-256 `19ee963bb9476a70732eaf338497e977eaa6abcffaddcd35c9ebbbeeac615bc3` | theme unit test | +| Semantic IR binary | magic `PLGCIR\0\0`, format 2; 309-byte golden SHA-256 `1d19d4bf7735acaf6b574d95fdb930389713c55983fff87c4c26a6cc2880532b`; append-only container, writing-mode, and cascade-layer extensions; embeds StyleId format 2 and ThemeId format 3 | compiler golden/round-trip tests | +| Minimal theme fixture | ID `eda25b5ed8fa8ce973662d4f18a46bdc` | theme unit test | +| Minimal theme binary | format 3, 104 bytes, SHA-256 `d2ce5bd919ba720e3b108fe1479e52c70b0441af708ddd5c7025e16fb2a0f4e0` | theme unit test | | Theme configuration | schema 1 | config tests and reference | | CSS manifest | schema 3 default; schema 4 semantic graph; schema 5 physical trace | CLI, manifest-graph, and physical-trace gates | | Nested provenance graph | schema 1 semantic; schema 2 physical superset; declaration/physical ID formats 1 | graph/trace gates and references | | Reachability sidecar | schema 1 | strict decoder and manifest-graph gate | | Structured diagnostics | schema 1 | CLI tests and process smoke | -| Inspect document | schema 2 | CLI tests | -| Catalog document | schema 3 | CLI tests and generated-reference smoke | +| Inspect document | schema 3 | CLI tests | +| Catalog document | schema 4 | CLI tests and generated-reference smoke | | Explain document | schema 2 | CLI tests and editor-query smoke | | Repair proposal / plan / dry-run report / Change Receipt | schemas `1.0.0` / `1.0.0` / `1.0.0` / `1.0.0`; plan mode `dry-run-only`; patch format `pliegocss-byte-edits/1`; receipt result `checks-pending` | agent unit tests, CLI black-box apply/rollback gate, public-API smoke, and extracted-package gate | | Repair check policy / Verification Receipt | latest schemas `1.4.0` / `1.4.0`, canonical `1.0.0`/`1.1.0`/`1.2.0`/`1.3.0` read support under original kind limits; kinds `standard-css-audit|token-graph-integrity|css-budget-audit|test-suite-evidence|browser-evidence`; fixed test/browser evidence schemas `1.0.0`; results `passed|failed|blocked`; complete adjacent FindingDocuments | agent core/bin tests, fixed-runner/verify black-box gates, real Chromium replay, public-API smoke, and extracted-package gate | | Declarative bundles | frozen plan schema 1 plus additive schema 2; per-bundle manifest schema 3 or explicit 4/5 | schema-1 bundle/graph/PliegoRS gates; schema-2 DTCG local gates green | | PliegoRS source-surface contract | schema 2 | cross-repository integration gate | -| Portable bundle bytes | CSS/manifest hashes in the portability contract | configured three-OS CI vector; hosted evidence pending | +| Portable bundle bytes | CSS/manifest hashes in the portability contract | three-OS × Rust 1.85/1.96 CI; every green run is exact-source evidence and cannot promote an older tag | | Browser lowering configuration | Chrome/Edge 111, Firefox 128, Safari 16.4 | CLI target tests and reference | | Compatibility policy | schema 2 / policy 7; frozen `baseline-widely` snapshot dated 2026-07-14, integrity-bound official data packages, plus bounded configurable attribute and typed direction/container/writing-mode/cascade-layer support | `pnpm check:compatibility`, policy golden, and ADR-0012/0013 | @@ -117,10 +117,10 @@ third transaction implementation should be introduced. contract makes the reason explicit; integrity hashes and portability vectors must move together. The JSON schema bumps associated with StyleId format 2 make identity versions self-describing at -the document boundary. Manifest schemas 3, 4, and 5, inspection schema 2, catalog schema 3, and +the document boundary. Manifest schemas 3, 4, and 5, inspection schema 3, catalog schema 4, and utility explain schema 2 require the top-level `styleIdFormatVersion`, `classNameFormatVersion`, and `themeIdFormatVersion` fields. Diagnostic schema 1, theme configuration schema 1, theme identity and -binary format 2, semantic IR binary format 2, bundle-plan schemas 1/2, reachability schema 1, nested +binary format 3, semantic IR binary format 2, bundle-plan schemas 1/2, reachability schema 1, nested graph schemas 1/2, cascade explain schema 1, repair proposal/plan/dry-run/Change Receipt schemas 1.0.0, check-policy/Verification Receipt schemas 1.4.0 with canonical 1.0.0/1.1.0/1.2.0/1.3.0 read support, fixed test/browser evidence schemas 1.0.0, patch format 1, @@ -153,10 +153,10 @@ Consumers moving an existing checkout must: `CLASS_NAME_FORMAT_VERSION` remains 1 because `pc_` plus lowercase base 36 did not change. Every previously generated class must nevertheless be treated as obsolete because the encoded StyleId -contract changed. ThemeId format 2 and theme-binary format 2 are also incompatible with their format-1 candidates. -Consumers must regenerate theme binaries and every ThemeId-derived artifact; the decoder rejects a -format-1 theme binary with an explicit unsupported-version error rather than reinterpreting its -stored FNV identity under SHA-256 semantics. +contract changed. ThemeId format 3 and theme-binary format 3 are also incompatible with older +candidates. Consumers must regenerate theme binaries and every ThemeId-derived artifact; the decoder +rejects an older theme binary with an explicit unsupported-version error rather than reinterpreting +a registry that has no explicit breakpoint cascade rank. ## Candidate SemVer policy @@ -173,9 +173,10 @@ For subsequent release candidates and the eventual `0.1.x` line: - Rust 1.85 remains the MSRV throughout `0.1.x`; raising it requires at least `0.2.0` and migration notes. -This policy governs the published `0.1.0-rc.2` prerelease. It does not claim the -separate final `0.1.0` promotion has occurred. The supported application/build -subset is selected and compile-checked through a registry-only Rust 1.85 consumer. +This policy governs the `0.1.0-rc.3` candidate. It does not claim publication, +registry replay, or separate final `0.1.0` promotion has occurred. The supported +application/build subset is selected and compile-checked through a Rust 1.85 +consumer shaped like the registry package graph. The intended RC surface includes `pc!`, `pcx!` (including Cargo facade dependency renames), `Style`/`StyleId` class interoperability, the diff --git a/docs/reference/engine-api.md b/docs/reference/engine-api.md new file mode 100644 index 0000000..803b55c --- /dev/null +++ b/docs/reference/engine-api.md @@ -0,0 +1,70 @@ +# Shared compiler engine + +Status: implemented G2 architecture; exact-version advanced API during `0.1.x` + +PliegoCSS exposes one I/O-free engine in `pliego-css-compiler`. CLI, watch, procedural macros, Rust +source scanning, and LSP transport adapt their own inputs and outputs around this boundary instead +of reimplementing semantic or physical decisions. + +## Primary contract + +```rust,no_run +use pliego_css_compiler::{AnalysisHost, CompileInput, CompileRequest}; + +let mut host = AnalysisHost::default(); +let result = host.compile(&CompileRequest { + inputs: vec![CompileInput::literal("flex gap-4")], + include_theme: false, + physical_trace: false, +})?; +# Ok::<(), Box>(()) +``` + +`CompileRequest → CompileResult` is the sole complete compile operation. It owns no filesystem, +environment, process, publication, target optimizer, or protocol behavior. Those remain adapter +boundaries. + +## Semantic identity versus physical planning + +`StyleId` identifies normalized semantic IR under one exact theme and stays independent from the +physical emission strategy. `PhysicalRulePlanner` consumes validated semantic styles, collision- +checks canonical identity streams, deduplicates them, emits deterministic fragments, and prunes its +cache after each complete snapshot. Later atomic, grouped, hybrid, or shared-chunk planners may +change physical rules without changing semantic identity. + +Target-specific Lightning CSS optimization, provenance, reachability selection, manifests, +publication locks, rollback, and receipts remain outside the planner. + +## Incremental host + +`AnalysisHost` owns: + +- one exact `ThemeRegistry`; +- a bounded literal-to-semantic cache, including stable failures; +- one `PhysicalRulePlanner` and its fragment cache; +- cumulative cache statistics for adapter observability. + +Changing the theme ID invalidates every theme-bound cache. CLI watch retains one host across +snapshots. The LSP retains one host per active theme/config identity and performs document version +checks around bounded work. + +## Shared conditional frontend + +`PcxRequest` contains a base literal and all visible branch literals grouped by independent clause. +`AnalysisHost::analyze_pcx` performs the shared operation in this order: + +1. parse and lower base and branches through the semantic cache; +2. reject cross-clause slot overlap as `PCX003`; +3. reject more than 64 combinations as `PCX004`; +4. compose every bounded cartesian selection in stable order. + +The procedural macro owns Rust expression syntax and runtime selectors. The source scanner owns Rust +byte ranges and provenance. The LSP owns UTF-16 projection. None owns a second conditional semantic +algorithm. + +## Stability + +These engine types are packaged because exact-version sibling tools need them, but remain an +advanced implementation API in `0.1.x`. The application API remains `pliego_css::{pc, pcx, Style, +StyleId}`. Promotion to a 1.0 library contract requires downstream compile fixtures, semver policy, +and an explicit public-API decision. diff --git a/docs/reference/generated-catalog.md b/docs/reference/generated-catalog.md index ab08c8e..fbd10c9 100644 --- a/docs/reference/generated-catalog.md +++ b/docs/reference/generated-catalog.md @@ -2,74 +2,74 @@ # PliegoCSS utility catalog -Theme ID: b3d5ad77175995c2b8f51ef7c0d41991 +Theme ID: b98b78da29201938d135b8bb94717788 -Identity formats: StyleId 2, class name 1, ThemeId 2 +Identity formats: StyleId 2, class name 1, ThemeId 3 ## Utilities | Pattern | Match | Form | Domain | Capabilities | Example | CSS | Summary | |---|---|---|---|---|---|---|---| -| [property:value] | | arbitrary-property | arbitrary-property | arbitrary-value | [mask-type:luminance] | .pc_90jbn16g8xs3raj9jq5txt7ky{mask-type:luminance} | Emit one parsed arbitrary CSS declaration. | -| antialiased | antialiased | fixed | none | none | antialiased | .pc_cweac74cfxlcfoseflffeyox3{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} | Enable platform font smoothing. | -| aspect-square | aspect-square | fixed | none | none | aspect-square | .pc_55wxt09qepbyuyhzoubgrp8mv{aspect-ratio:1} | Set a one-to-one aspect ratio. | -| bg-{color} | bg | parameterized | color | arbitrary-value, custom-property, modifier | bg-accent/20 | .pc_ckn2d0zesr9si73hx6cxwj4hd{background-color:color-mix(in oklab,var(--color-accent) 20%,transparent)} | Set the background color with optional named-token alpha. | -| block | block | fixed | none | none | block | .pc_11340wm8ue1yzys72dq1fs2qn{display:block} | Use block display. | -| border | border | fixed | none | none | border | .pc_dvxt11rxb0khipwqt4gh41890{border-width:1px;border-style:solid} | Set all border widths to one pixel with solid style. | -| border-b | border-b | fixed | none | none | border-b | .pc_3ci9dol8oqt347vxjhx1t43id{border-bottom-width:1px;border-bottom-style:solid} | Set the bottom border width to one pixel with solid style. | -| border-{width-or-color} | border | parameterized | border-width-or-color | none | border-2 | .pc_aqdp2val6hl1lc4vdb5jzmu3m{border-width:2px;border-style:solid} | Set all border widths in pixels or set the border color. | -| col-span-{n} | col-span | parameterized | grid-column-span | none | col-span-2 | .pc_efn1ei6zp7vzh2kir8cwggdjd{grid-column:span 2/span 2} | Span one through twelve grid columns. | -| container-inline | container-inline | fixed | none | none | container-inline | .pc_6g3aza3qqu26w1tvj5cura0qr{container-type:inline-size} | Establish an inline-size query container. | -| container-normal | container-normal | fixed | none | none | container-normal | .pc_djfko9bt4e9bftrdomgm1n46r{container-type:normal} | Disable size-query containment. | -| cursor-not-allowed | cursor-not-allowed | fixed | none | none | cursor-not-allowed | .pc_217o07dreaw53gn6rxyvhzoca{cursor:not-allowed} | Use the not-allowed cursor. | -| cursor-pointer | cursor-pointer | fixed | none | none | cursor-pointer | .pc_b5coidj10znziv0o4mqtcgfmv{cursor:pointer} | Use the pointer cursor. | -| flex | flex | fixed | none | none | flex | .pc_ddlill52a5p39osfnemitptkc{display:flex} | Use flex display. | -| flex-1 | flex-1 | fixed | none | none | flex-1 | .pc_e72wtvtzal0hpv2c59asge73{flex-grow:1;flex-shrink:1;flex-basis:0%} | Set flex grow and shrink to one with a zero-percent basis. | -| flex-col | flex-col | fixed | none | none | flex-col | .pc_6w2dqw01d84npdiuz7ej99z9n{flex-direction:column} | Lay out flex items in a column. | -| flex-row | flex-row | fixed | none | none | flex-row | .pc_5bor40m0bco1vtevgoz1oj2t6{flex-direction:row} | Lay out flex items in a row. | -| flex-wrap | flex-wrap | fixed | none | none | flex-wrap | .pc_boc158u3hb242jo6k06gj4f6w{flex-wrap:wrap} | Allow flex items to wrap. | -| font-{family-or-weight} | font | parameterized | font-family-or-weight | none | font-semibold | .pc_9hnopdixbijow17i23ww9ytjz{font-weight:600} | Set a registered font family or font weight. | -| gap-{space} | gap | parameterized | spacing | arbitrary-value, custom-property | gap-4 | .pc_bk89fbsw4a72vaguue8b1dubm{gap:1rem} | Set row and column gap from one spacing value. | -| grid | grid | fixed | none | none | grid | .pc_5ezmrouunnef1ulz4bo5gyrq3{display:grid} | Use grid display. | -| grid-cols-{value} | grid-cols | parameterized | grid-template | arbitrary-value, custom-property | grid-cols-[1fr 2fr] | .pc_59o9ufvvg69gjxxz262a5t0iy{grid-template-columns:1fr 2fr} | Set a grid column count or explicit template. | -| h-{size} | h | parameterized | size | arbitrary-value, custom-property | h-full | .pc_bjw5g39k92pvnhlputh8qia48{height:100%} | Set height from the size domain. | -| hidden | hidden | fixed | none | none | hidden | .pc_5gta22yo1fuga2bnf66d59dal{display:none} | Use none display. | -| inline-block | inline-block | fixed | none | none | inline-block | .pc_1uw87eycqqa1bobkrng2azv0e{display:inline-block} | Use inline-block display. | -| inline-flex | inline-flex | fixed | none | none | inline-flex | .pc_18pykli966y1z59l7khalaufr{display:inline-flex} | Use inline-flex display. | -| items-center | items-center | fixed | none | none | items-center | .pc_bxkwuhz64dp87d6n4hochf2cs{align-items:center} | Center items on the cross axis. | -| items-end | items-end | fixed | none | none | items-end | .pc_8pbwdrjt1z4bjnnjbcqj69cuq{align-items:end} | Align items to the cross-axis end. | -| items-start | items-start | fixed | none | none | items-start | .pc_5fngqgj5t03lttmim9rh7k6s{align-items:start} | Align items to the cross-axis start. | -| justify-between | justify-between | fixed | none | none | justify-between | .pc_2k703u6k30bxtqb3v2eansu4a{justify-content:space-between} | Distribute items with space between them. | -| justify-center | justify-center | fixed | none | none | justify-center | .pc_ydlz64r4ktzyqvjupt8f4vyy{justify-content:center} | Center items on the main axis. | -| justify-end | justify-end | fixed | none | none | justify-end | .pc_bhdk08uzj7t9r11r7hrcxly3b{justify-content:end} | Align items to the main-axis end. | -| justify-start | justify-start | fixed | none | none | justify-start | .pc_3xwd28heju5qozeknop14ep39{justify-content:start} | Align items to the main-axis start. | -| leading-{line-height} | leading | parameterized | line-height | arbitrary-value, custom-property | leading-6 | .pc_3zpeaughafqdvzjluqfta6hwm{line-height:1.5rem} | Set line height from the line-height domain. | -| m-{value} | m | parameterized | margin | negative, arbitrary-value, custom-property | -m-4 | .pc_8tgg7v0riuwuxs5wqs643qmgk{margin:-1rem} | Set all margins from spacing or auto. | -| max-w-{size} | max-w | parameterized | size | arbitrary-value, custom-property | max-w-6xl | .pc_7uk3rfdh9y5rykwlr4sfr71de{max-width:72rem} | Set maximum width from the size domain. | -| min-h-{size} | min-h | parameterized | size | arbitrary-value, custom-property | min-h-0 | .pc_10ifxt13gorhy536y3bjkjps8{min-height:0} | Set minimum height from the size domain. | -| min-w-{size} | min-w | parameterized | size | arbitrary-value, custom-property | min-w-0 | .pc_4lmgjibe0qazi4e96zmuwhwn7{min-width:0} | Set minimum width from the size domain. | -| mt-{value} | mt | parameterized | margin | negative, arbitrary-value, custom-property | -mt-4 | .pc_en25e1e9s3shr3wqzx7f3zjqh{margin-top:-1rem} | Set top margin from spacing or auto. | -| mx-{value} | mx | parameterized | margin | negative, arbitrary-value, custom-property | -mx-4 | .pc_3fihk9k1bmmrxdhp6mi3dgslk{margin-right:-1rem;margin-left:-1rem} | Set horizontal margins from spacing or auto. | -| opacity-{percentage} | opacity | parameterized | opacity-percentage | none | opacity-50 | .pc_516a2o6hzmr4i040nis9pw58z{opacity:.5} | Set opacity from zero through one hundred percent. | -| outline-2 | outline-2 | fixed | none | none | outline-2 | .pc_49z8uhzvs5jhe0j18h6z8ysn3{outline-width:2px;outline-style:solid} | Set outline width to two pixels. | -| outline-accent | outline-accent | fixed | none | none | outline-accent | .pc_cg766b8pvkhsclx9klp51bbww{outline-color:var(--color-accent)} | Set outline color from the accent token. | -| outline-none | outline-none | fixed | none | none | outline-none | .pc_3ow4zm0mtnml5yg2arpy42pwu{outline-style:none} | Disable the outline style. | -| outline-offset-2 | outline-offset-2 | fixed | none | none | outline-offset-2 | .pc_1r4qla0to76q4tti83p22kcm1{outline-offset:2px} | Offset the outline by two pixels. | -| p-{space} | p | parameterized | spacing | arbitrary-value, custom-property | p-4 | .pc_595dj5pk381winfzvy5inds9f{padding:1rem} | Set all padding sides from one spacing value. | -| pb-{space} | pb | parameterized | spacing | arbitrary-value, custom-property | pb-4 | .pc_85p1evlpyxm8n1o7twajw8k4c{padding-bottom:1rem} | Set bottom padding from one spacing value. | -| px-{space} | px | parameterized | spacing | arbitrary-value, custom-property | px-4 | .pc_8w1x3b7ezhid3rixwv6ff1tbt{padding-right:1rem;padding-left:1rem} | Set horizontal padding from one spacing value. | -| py-{space} | py | parameterized | spacing | arbitrary-value, custom-property | py-4 | .pc_6lufe7xchslj6sah8ss93tutk{padding-top:1rem;padding-bottom:1rem} | Set vertical padding from one spacing value. | -| resize-y | resize-y | fixed | none | none | resize-y | .pc_37qs40fr2dhmwhyvomgpxedrd{resize:vertical} | Allow vertical resizing. | -| ring-{width-or-color} | ring | parameterized | ring-width-or-color | modifier | ring-accent/20 | .pc_awd7yc4v4t8pfj90brwxxd6cf{--pc-shadow:0 0 #0000;--pc-ring-width:0;--pc-ring-color:currentColor;--pc-ring-color:color-mix(in oklab,var(--color-accent) 20%,transparent);box-shadow:0 0 0 var(--pc-ring-width,0) var(--pc-ring-color,currentColor),var(--pc-shadow,0 0 #0000)} | Set focus-ring width in pixels or set its color. | -| rounded-{radius} | rounded | parameterized | radius | arbitrary-value, custom-property | rounded-lg | .pc_39xkg6lc3evoe7nkrdhhy8qe2{border-radius:.5rem} | Set all corner radii from one radius value. | -| shadow-{shadow} | shadow | parameterized | shadow | arbitrary-value, custom-property | shadow-md | .pc_i3wu9af1v7x2nzb1hd43nnja{--pc-shadow:0 0 #0000;--pc-ring-width:0;--pc-ring-color:currentColor;--pc-shadow:0 4px 6px -1px #0000001a;box-shadow:0 0 0 var(--pc-ring-width,0) var(--pc-ring-color,currentColor),var(--pc-shadow,0 0 #0000)} | Set the composed box-shadow value. | -| text-{size-or-color} | text | parameterized | font-size-or-color | custom-property, modifier | text-sm/6 | .pc_cek8klq5f4fbtenechqlckokf{font-size:.875rem;line-height:1.25rem;line-height:1.5rem} | Set font size or text color with the supported modifier. | -| tracking-tight | tracking-tight | fixed | none | negative | -tracking-tight | .pc_5g1lrku7i8mgilyd3dvwh3efy{letter-spacing:.025em} | Set tight letter spacing, optionally negated. | -| transition-colors | transition-colors | fixed | none | none | transition-colors | .pc_db69ko23cdd794nvwvwypu1iv{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)} | Transition the compiler-defined color property bundle. | -| w-{size} | w | parameterized | size | arbitrary-value, custom-property | w-full | .pc_p74tboen32syz5iiyutp41yt{width:100%} | Set width from the size domain. | -| writing-horizontal | writing-horizontal | fixed | none | none | writing-horizontal | .pc_d6rsbq6gz3yy8nvj5x6eff2gi{writing-mode:horizontal-tb} | Use horizontal top-to-bottom writing mode. | -| writing-vertical-lr | writing-vertical-lr | fixed | none | none | writing-vertical-lr | .pc_8s3cbvw2k8vxr1go2u41jylm6{writing-mode:vertical-lr} | Use vertical writing with left-to-right block flow. | -| writing-vertical-rl | writing-vertical-rl | fixed | none | none | writing-vertical-rl | .pc_2py1trrr2aqdoizbkeci6r8lz{writing-mode:vertical-rl} | Use vertical writing with right-to-left block flow. | +| [property:value] | | arbitrary-property | arbitrary-property | arbitrary-value | [mask-type:luminance] | .pc_28oa5ebiymd12c8v5osz4w1vv{mask-type:luminance} | Emit one parsed arbitrary CSS declaration. | +| antialiased | antialiased | fixed | none | none | antialiased | .pc_b1h7ctd16ln3f9o879ajt3s9q{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} | Enable platform font smoothing. | +| aspect-square | aspect-square | fixed | none | none | aspect-square | .pc_bvwg9dpdc7ib18xm2ibcxi8ug{aspect-ratio:1} | Set a one-to-one aspect ratio. | +| bg-{color} | bg | parameterized | color | arbitrary-value, custom-property, modifier | bg-accent/20 | .pc_6qczwr4g417yod3s2yxhs561a{background-color:color-mix(in oklab,var(--color-accent) 20%,transparent)} | Set the background color with optional named-token alpha. | +| block | block | fixed | none | none | block | .pc_axlg9s0759ac530lrwli708ge{display:block} | Use block display. | +| border | border | fixed | none | none | border | .pc_8gs0qt9y10a6q5j8leb6ip0v5{border-width:1px;border-style:solid} | Set all border widths to one pixel with solid style. | +| border-b | border-b | fixed | none | none | border-b | .pc_d8rmvzxe99vh5werhx9axkgdy{border-bottom-width:1px;border-bottom-style:solid} | Set the bottom border width to one pixel with solid style. | +| border-{width-or-color} | border | parameterized | border-width-or-color | none | border-2 | .pc_6yhudj91fax2ip81y8gr6uuuy{border-width:2px;border-style:solid} | Set all border widths in pixels or set the border color. | +| col-span-{n} | col-span | parameterized | grid-column-span | none | col-span-2 | .pc_en1zh3waan1u74cx7m3q3jxs0{grid-column:span 2/span 2} | Span one through twelve grid columns. | +| container-inline | container-inline | fixed | none | none | container-inline | .pc_20eex3v1vmorf1pix4pzasm11{container-type:inline-size} | Establish an inline-size query container. | +| container-normal | container-normal | fixed | none | none | container-normal | .pc_45nvw3zrd4yi1p5zfowa8dwgz{container-type:normal} | Disable size-query containment. | +| cursor-not-allowed | cursor-not-allowed | fixed | none | none | cursor-not-allowed | .pc_6maem1787z5qgmpmd3r3a33pa{cursor:not-allowed} | Use the not-allowed cursor. | +| cursor-pointer | cursor-pointer | fixed | none | none | cursor-pointer | .pc_4p2hu9dlkwx98kauoh24u5vdd{cursor:pointer} | Use the pointer cursor. | +| flex | flex | fixed | none | none | flex | .pc_3kz8pmwcy005k05ceo9sih9uk{display:flex} | Use flex display. | +| flex-1 | flex-1 | fixed | none | none | flex-1 | .pc_77wg4j4n9du1k0uveexezd8xk{flex-grow:1;flex-shrink:1;flex-basis:0%} | Set flex grow and shrink to one with a zero-percent basis. | +| flex-col | flex-col | fixed | none | none | flex-col | .pc_dlcrxte81ff0ohk6eyv3zb1sj{flex-direction:column} | Lay out flex items in a column. | +| flex-row | flex-row | fixed | none | none | flex-row | .pc_7mr5e2djhoamvbe2a3yl9u3xf{flex-direction:row} | Lay out flex items in a row. | +| flex-wrap | flex-wrap | fixed | none | none | flex-wrap | .pc_8euf2gyoz2fop6bgdllrodj4l{flex-wrap:wrap} | Allow flex items to wrap. | +| font-{family-or-weight} | font | parameterized | font-family-or-weight | none | font-semibold | .pc_9l5bnrf81pmzbzmb6ztqk8qg8{font-weight:600} | Set a registered font family or font weight. | +| gap-{space} | gap | parameterized | spacing | arbitrary-value, custom-property | gap-4 | .pc_22avs875ur7iem8cd7fjv3g5g{gap:1rem} | Set row and column gap from one spacing value. | +| grid | grid | fixed | none | none | grid | .pc_amxmlp30jk0sp630gla7rf9pf{display:grid} | Use grid display. | +| grid-cols-{value} | grid-cols | parameterized | grid-template | arbitrary-value, custom-property | grid-cols-[1fr 2fr] | .pc_4ytqim6azkgcbm1348fkqbgjd{grid-template-columns:1fr 2fr} | Set a grid column count or explicit template. | +| h-{size} | h | parameterized | size | arbitrary-value, custom-property | h-full | .pc_8jaev1osn87d9jjy3r7lgnafg{height:100%} | Set height from the size domain. | +| hidden | hidden | fixed | none | none | hidden | .pc_297l7w3wemfz683380x1id1ap{display:none} | Use none display. | +| inline-block | inline-block | fixed | none | none | inline-block | .pc_2pcgqy4zpg9n3h019mc96ifkb{display:inline-block} | Use inline-block display. | +| inline-flex | inline-flex | fixed | none | none | inline-flex | .pc_1wnirt6p4vgksoerjvbd9vp3f{display:inline-flex} | Use inline-flex display. | +| items-center | items-center | fixed | none | none | items-center | .pc_2jg116v3gu9f012gjiuu6zhl1{align-items:center} | Center items on the cross axis. | +| items-end | items-end | fixed | none | none | items-end | .pc_d8egzo5z6qx6b18khy0oybr3z{align-items:end} | Align items to the cross-axis end. | +| items-start | items-start | fixed | none | none | items-start | .pc_8hlakyvrcs0dwzp062v5wqa1g{align-items:start} | Align items to the cross-axis start. | +| justify-between | justify-between | fixed | none | none | justify-between | .pc_ezpgekofdw36xaaakqaurlebn{justify-content:space-between} | Distribute items with space between them. | +| justify-center | justify-center | fixed | none | none | justify-center | .pc_fy7j4m8ugmf23hbhwi9a3m8k{justify-content:center} | Center items on the main axis. | +| justify-end | justify-end | fixed | none | none | justify-end | .pc_4xuyx10bbjle9frky18rmheto{justify-content:end} | Align items to the main-axis end. | +| justify-start | justify-start | fixed | none | none | justify-start | .pc_9x2hos0rsm46pi45pyg1796qy{justify-content:start} | Align items to the main-axis start. | +| leading-{line-height} | leading | parameterized | line-height | arbitrary-value, custom-property | leading-6 | .pc_a11r2pil5qqkm9bhrki6t5uu9{line-height:1.5rem} | Set line height from the line-height domain. | +| m-{value} | m | parameterized | margin | negative, arbitrary-value, custom-property | -m-4 | .pc_866dg934rlp9c5o42xit8zt6d{margin:-1rem} | Set all margins from spacing or auto. | +| max-w-{size} | max-w | parameterized | size | arbitrary-value, custom-property | max-w-6xl | .pc_8t7g8oq5oqny8ugbo6x4syr8q{max-width:72rem} | Set maximum width from the size domain. | +| min-h-{size} | min-h | parameterized | size | arbitrary-value, custom-property | min-h-0 | .pc_4795w9lt9izc7lx2jqwddx8yt{min-height:0} | Set minimum height from the size domain. | +| min-w-{size} | min-w | parameterized | size | arbitrary-value, custom-property | min-w-0 | .pc_9pzfrf4pd9hbsyuo17hnnwxgq{min-width:0} | Set minimum width from the size domain. | +| mt-{value} | mt | parameterized | margin | negative, arbitrary-value, custom-property | -mt-4 | .pc_3v7gx79z9p8l8pnvys1h19cgv{margin-top:-1rem} | Set top margin from spacing or auto. | +| mx-{value} | mx | parameterized | margin | negative, arbitrary-value, custom-property | -mx-4 | .pc_ax2yjl9py3ite625rmrim0wt6{margin-right:-1rem;margin-left:-1rem} | Set horizontal margins from spacing or auto. | +| opacity-{percentage} | opacity | parameterized | opacity-percentage | none | opacity-50 | .pc_2zf3kka46ql8kspjxspkzp3kz{opacity:.5} | Set opacity from zero through one hundred percent. | +| outline-2 | outline-2 | fixed | none | none | outline-2 | .pc_4p5brs81xzwfsxyvtgsp2v4i7{outline-width:2px;outline-style:solid} | Set outline width to two pixels. | +| outline-accent | outline-accent | fixed | none | none | outline-accent | .pc_ari3p8cbz68nak6ot1xf84z94{outline-color:var(--color-accent)} | Set outline color from the accent token. | +| outline-none | outline-none | fixed | none | none | outline-none | .pc_76a0isafwis6phn0eiftv2kdh{outline-style:none} | Disable the outline style. | +| outline-offset-2 | outline-offset-2 | fixed | none | none | outline-offset-2 | .pc_ay5cr66qgpc50elk9aff79mv7{outline-offset:2px} | Offset the outline by two pixels. | +| p-{space} | p | parameterized | spacing | arbitrary-value, custom-property | p-4 | .pc_e4pctv7mkogg6mh7donuaw8cq{padding:1rem} | Set all padding sides from one spacing value. | +| pb-{space} | pb | parameterized | spacing | arbitrary-value, custom-property | pb-4 | .pc_ae09smvc1ie3wslg2ujp8t7d4{padding-bottom:1rem} | Set bottom padding from one spacing value. | +| px-{space} | px | parameterized | spacing | arbitrary-value, custom-property | px-4 | .pc_2ecsfnutp2rchfibgm5tlju7g{padding-right:1rem;padding-left:1rem} | Set horizontal padding from one spacing value. | +| py-{space} | py | parameterized | spacing | arbitrary-value, custom-property | py-4 | .pc_4z17nbro259wb81vpmkxxrnvv{padding-top:1rem;padding-bottom:1rem} | Set vertical padding from one spacing value. | +| resize-y | resize-y | fixed | none | none | resize-y | .pc_e9jv53huml1xjv3fkpvq5flc{resize:vertical} | Allow vertical resizing. | +| ring-{width-or-color} | ring | parameterized | ring-width-or-color | modifier | ring-accent/20 | .pc_4eyc58suxnfcjiw95bm2ses8m{--pc-shadow:0 0 #0000;--pc-ring-width:0;--pc-ring-color:currentColor;--pc-ring-color:color-mix(in oklab,var(--color-accent) 20%,transparent);box-shadow:0 0 0 var(--pc-ring-width,0) var(--pc-ring-color,currentColor),var(--pc-shadow,0 0 #0000)} | Set focus-ring width in pixels or set its color. | +| rounded-{radius} | rounded | parameterized | radius | arbitrary-value, custom-property | rounded-lg | .pc_5rps1losic6fe536q2l33hep{border-radius:.5rem} | Set all corner radii from one radius value. | +| shadow-{shadow} | shadow | parameterized | shadow | arbitrary-value, custom-property | shadow-md | .pc_1jqgpu2z41t72cryqx3jwt3wz{--pc-shadow:0 0 #0000;--pc-ring-width:0;--pc-ring-color:currentColor;--pc-shadow:0 4px 6px -1px #0000001a;box-shadow:0 0 0 var(--pc-ring-width,0) var(--pc-ring-color,currentColor),var(--pc-shadow,0 0 #0000)} | Set the composed box-shadow value. | +| text-{size-or-color} | text | parameterized | font-size-or-color | custom-property, modifier | text-sm/6 | .pc_4s2wqpgiueral6gkxcgvjyqt2{font-size:.875rem;line-height:1.25rem;line-height:1.5rem} | Set font size or text color with the supported modifier. | +| tracking-tight | tracking-tight | fixed | none | negative | -tracking-tight | .pc_755gq9w983piddlqinbv0nq8r{letter-spacing:.025em} | Set tight letter spacing, optionally negated. | +| transition-colors | transition-colors | fixed | none | none | transition-colors | .pc_4gsr5ihv66onzzfrrwaypmaut{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)} | Transition the compiler-defined color property bundle. | +| w-{size} | w | parameterized | size | arbitrary-value, custom-property | w-full | .pc_5n9hx3ynrvx37e0wb98wcuv28{width:100%} | Set width from the size domain. | +| writing-horizontal | writing-horizontal | fixed | none | none | writing-horizontal | .pc_yowlmeg1ennmiaqildbqufx1{writing-mode:horizontal-tb} | Use horizontal top-to-bottom writing mode. | +| writing-vertical-lr | writing-vertical-lr | fixed | none | none | writing-vertical-lr | .pc_2gniebrd4j98kt8cc1nhi94l2{writing-mode:vertical-lr} | Use vertical writing with left-to-right block flow. | +| writing-vertical-rl | writing-vertical-rl | fixed | none | none | writing-vertical-rl | .pc_a4icxk6oqgay305sckdvfhru0{writing-mode:vertical-rl} | Use vertical writing with right-to-left block flow. | ## Tokens @@ -129,8 +129,8 @@ Identity formats: StyleId 2, class name 1, ThemeId lg | 64rem | -| md | 48rem | -| sm | 40rem | +| Cascade rank | Name | Minimum width | +|---:|---|---| +| 0 | sm | 40rem | +| 1 | md | 48rem | +| 2 | lg | 64rem | diff --git a/docs/reference/lsp-diagnostic-corpus.md b/docs/reference/lsp-diagnostic-corpus.md index c15adee..e1496ad 100644 --- a/docs/reference/lsp-diagnostic-corpus.md +++ b/docs/reference/lsp-diagnostic-corpus.md @@ -38,7 +38,7 @@ message, and style byte range. Source cases use `check --source`; `FMT001` uses `fmt --source --check`. The LSP session then applies each input as a new full-document version, waits for that version's publication, rejects additional diagnostic codes, and requires exact CLI code, message, severity, category, suggestion, typed replacement, and UTF-16 range projection. -The same session independently compares compiler-backed cross-clause `PCX003` over all those fields. +The same session independently compares shared-engine cross-clause `PCX003` over all those fields. The corpus exposed three prior mismatches: parser failures used the complete Rust literal range in the LSP even though the parser and CLI retained an exact `Diagnostic.span`; zero-width `PCS008` ranges @@ -50,9 +50,9 @@ still fail closed to the complete token. ## Boundary This closes the current typed utility-authoring families: PCS, PSC, PCR001, FMT001, and PCX003. -`PCL001`/`PCL002` remain operational fallback paths for unavailable or invalid tools and bounded -editor limits, so the same session covers them through invalid-JSON and 256-literal fault injection -rather than authoring corpus inputs. Future codes must add corpus or fault-injection coverage before -this coverage claim can remain true. Adding a case is append-only inside schema 2 when its fields and -interpretation are unchanged. Changing the root shape, range meaning, or equality dimensions -requires a new schema version and migration note. +`PCL001`/`PCL002` remain operational paths for invalid theme/configuration state and bounded editor +limits rather than authoring corpus inputs. The session exercises the 256-literal `PCL002` boundary +and passes a nonexistent legacy compiler path to prove it is not executed. Future authoring codes +must add corpus coverage before this claim can remain true. Adding a case is append-only inside +schema 2 when its fields and interpretation are unchanged. Changing the root shape, range meaning, +or equality dimensions requires a new schema version and migration note. diff --git a/docs/reference/lsp.md b/docs/reference/lsp.md index c4ebc36..67d51f3 100644 --- a/docs/reference/lsp.md +++ b/docs/reference/lsp.md @@ -1,195 +1,112 @@ # Language Server Protocol transport -`pliego-css-lsp` is the initial standard-LSP transport for PliegoCSS Rust literals. It implements a -bounded subset of the official [LSP 3.18 specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/) -over framed JSON-RPC on stdin/stdout. The transport owns protocol and document-coordinate concerns; -it does not fork the compiler's utility catalog or explanation semantics. +`pliego-css-lsp` implements a bounded subset of the official +[LSP 3.18 specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/) +over framed JSON-RPC on stdin/stdout. Protocol transport and document coordinates remain adapter +concerns; parsing, semantic lowering, conditional analysis, catalog resolution, and physical CSS +emission come from the shared in-process `AnalysisHost`. ## Start the server -Install or build `pliego-css-lsp` and `pliego-cssc` from the exact same PliegoCSS version, then start -the server from the project root: +Install `pliego-css-lsp` from the same PliegoCSS compatibility unit used by the project: ```console -pliego-css-lsp --pliego-cssc /exact/path/to/pliego-cssc --seed +cargo install pliego-css-lsp --version '=0.1.0-rc.3' --locked +pliego-css-lsp --seed ``` -`PLIEGO_CSSC` is the default compiler selector when `--pliego-cssc` is omitted. Choose exactly one -theme mode: +Choose exactly one theme mode: ```console -# Deterministic built-in seed theme +# Deterministic built-in registry pliego-css-lsp --seed -# Exact project theme +# Exact project theme; relative paths resolve from initialize.rootUri pliego-css-lsp --config pliego.theme.toml -# No theme flag: conventional discovery from the initialize root +# No theme flag: discover initialize.rootUri/pliego.theme.toml, else seed pliego-css-lsp # Optional verified source-to-final-CSS navigation pliego-css-lsp --project-index target/site/assets/pliego.index.json ``` -The server runs delegated compiler commands from the `initialize.rootUri` file directory. Compiler -startup failures and unsupported catalog/explain schema versions fail the affected request instead -of returning guessed metadata. +The 0.1 options `--pliego-cssc PATH` and `PLIEGO_CSSC` remain syntactically accepted so existing +clients keep starting, but the path is ignored and excluded from cache identity. The LSP never +executes a compiler child. ## Implemented protocol surface The initialize result selects `positionEncoding: "utf-16"`, requests full-document synchronization, -and advertises completion, hover, document formatting, and optionally definition when a Project -Index is configured. The current closed method set is: +and advertises completion, hover, document formatting, and optionally definition. | Direction | Method | Contract | |---|---|---| | client to server | `initialize`, `initialized`, `shutdown`, `exit` | Process lifecycle and one file-root workspace. | | client to server | `textDocument/didOpen`, `didChange`, `didClose` | In-memory, monotonically versioned, full-document buffers. | -| server to client | `textDocument/publishDiagnostics` | Rust parse/scanner diagnostics, PCS syntax diagnostics, compiler-backed per-literal semantic diagnostics, and `FMT001` drift. | -| request | `textDocument/completion` | Catalog schema 3 examples and summaries, with a whole-utility `textEdit`. | -| request | `textDocument/hover` | Explain schema 2 pattern, summary, and emitted CSS. | -| request | `textDocument/definition` | Exact Project Index source site to integrity-bound manifest and final CSS declaration links. Advertised only with `--project-index`. | -| request | `textDocument/formatting` | Complete Rust literal-token edits produced from canonical decoded values. | - -Input frames are capped at 16 MiB. LSP lines and characters are zero-based UTF-16 positions; the -server converts them from exact UTF-8 source bytes and rejects a position that splits a surrogate -pair. This is intentionally different from CLI diagnostic columns, which count Unicode scalar -values. - -Completion and hover operate only inside statically supported `pc!` and `pcx!` string literals. Raw -strings and ordinary strings without escapes have an exact source-to-decoded mapping. A cooked -literal containing escapes fails closed for cursor features because a decoded offset cannot safely -be treated as a Rust source offset. Document formatting remains safe in that case because it -replaces the complete literal token with a newly escaped ordinary Rust string. - -## Consistency boundary - -- Completion invokes `pliego-cssc catalog --format json` once and requires catalog schema 3. -- Hover invokes `pliego-cssc explain --style ... --format json` and requires explain schema 2. -- Open-buffer syntax and formatting use the same `pliego-css-source` scanner and - `pliego-css-parser` canonical formatter as the CLI. -- Each syntactically valid visible literal is checked through same-version - `pliego-cssc --diagnostic-format json check --style ...`. Schema-1 code, severity, message, - suggestion, replacement, and decoded style range are validated before projection into LSP - UTF-16 coordinates. Cooked strings with escapes use the safe whole-literal range. -- Semantic results are cached by exact decoded literal, capped at 4,096 entries, and each document - is capped at 256 semantic literal checks. Exceeding that boundary emits `PCL002` rather than - starting an unbounded number of compiler processes. -- Full-buffer changes replace the pending semantic job for that URI and restart a 150 ms debounce. - Due jobs run on a dedicated worker instead of the JSON-RPC thread. A per-URI version registry - terminates and reaps the direct compiler child when its snapshot becomes stale; cancelled work is - neither cached nor published. Stdout and stderr are drained concurrently so a full process pipe - cannot prevent cancellation. -- Multi-clause `pcx!` invocations are projected into one bounded synthetic Rust source containing - only visible utility literals. The worker delegates that document to same-version - `pliego-cssc check --source`, accepts only compiler `PCX003` cross-clause findings, and maps the - synthetic branch range back to the original complete Rust literal token. The temporary source is - created exclusively and removed after the compiler exits; it is never written into the project. -- Formatting returns edits only; it never writes a source file. The editor remains responsible for - applying the version-bound edit set. -- Definition consumes Project Index schema 1 or 2 instead of scanning repository structure. It - verifies the Asset Plan, source snapshot, bundle ownership, manifest/CSS hashes, schema-5 physical - contract, and exact declaration ranges on every request. Any stale or mixed artifact fails the - request closed. - -The current diagnostic pass covers Rust parsing, macro extraction, PliegoCSS syntax, canonical -formatting, and theme-aware compiler validation of every bounded individual literal. Local findings -publish immediately; uncached compiler checks are debounced and run serially in the background. -Cancellation is scoped to the direct `pliego-cssc` child for one URI/version; PliegoCSS does not -claim arbitrary descendant process-tree termination for a replacement compiler executable. -Compiler-backed `PCX003` -parity now covers semantic overlaps and exact duplicates across independently selectable clauses. -The versioned twenty-case negative corpus freezes exact code, message, range, severity, suggestion, -and typed replacement equality for PCS001–PCS012, PSC001–PSC006, PCR001, and FMT001. The same -session compares PCX003 and fault-injects PCL001/PCL002, closing the current CLI/editor diagnostic -families. New diagnostic codes must add coverage before that claim can remain true. +| server to client | `textDocument/publishDiagnostics` | Scanner, PCS semantic, shared `pcx`, and `FMT001` findings. | +| request | `textDocument/completion` | Compiler catalog examples and summaries with a whole-utility `textEdit`. | +| request | `textDocument/hover` | Shared catalog pattern, summary, and compiler-emitted CSS. | +| request | `textDocument/definition` | Verified Project Index source site to final CSS declaration links. | +| request | `textDocument/formatting` | Complete Rust literal-token edits from canonical decoded values. | + +Input frames are capped at 16 MiB. Lines and characters are zero-based UTF-16 positions. The server +converts them from exact UTF-8 source bytes and rejects a position that splits a surrogate pair. +Cursor features fail closed for cooked literals containing escapes because decoded offsets cannot +be treated as Rust source offsets. Formatting remains safe because it replaces the complete token. + +## Shared-engine consistency boundary + +- Completion reads `utility_catalog()` directly; it does not deserialize catalog JSON. +- Hover resolves the accepted `StyleItem`, lowers it through `AnalysisHost`, and emits CSS with the + host's exact theme. +- Every syntactically valid literal uses the same parser, semantic cache, and stable `Diagnostic` + type as CLI and watch. +- Macro expansion, source scanning, and LSP diagnostics all send the same `PcxRequest` to the + bounded conditional frontend. `PCX003` targets the original conflicting branch token; no + synthetic source file exists. +- The host cache is theme-bound and capped. A theme ID change invalidates semantic and physical + entries. Each document remains capped at 256 semantic checks and emits `PCL002` at the boundary. +- Full-buffer changes replace the pending job for that URI and restart a 150 ms debounce. Due work + runs on a dedicated in-process worker. Version checks before and after analysis suppress stale + publication without child-process cancellation. +- Definition consumes Project Index schema 1 or 2 and verifies adjacent source, Asset Plan, + manifest, CSS hashes, and exact declaration ranges on each request. + +The versioned twenty-case corpus freezes exact code, message, range, severity, suggestion, and +replacement equality for PCS001–PCS012, PSC001–PSC006, PCR001, and FMT001. The same session compares +CLI and LSP `PCX003`, exercises the 256-literal guard, passes a deliberately nonexistent legacy +compiler path, and reports `compilerProcesses: 0`. ## Current non-goals -This candidate does not yet provide incremental document changes, workspace folders, code actions, -semantic tokens, or a hosted multi-editor matrix. Those remain -release gates; the existence -of the stdio server and initial VS Code package does not close the full F6 editor-tooling task. +This candidate does not yet provide incremental text changes, multi-root workspaces, code actions, +semantic tokens, signed editor packages, or a hosted multi-editor matrix. Full-document sync and a +single local root remain explicit prerelease limits. -Run the reproducible local process gate with: +Run the reproducible gate with: ```console pnpm integration:lsp ``` -`pliego-css-lsp --version` prints the exact package version and exits. It is an exclusive process -option; combining it with server, compiler, theme, or Project Index arguments fails instead of -starting an ambiguously configured transport. - -The gate builds both binaries with Rust 1.85, performs one framed stdio session, and asserts UTF-16 -initialization, `FMT001` publication, whole-literal formatting, exact completion replacement, -compiler-backed hover CSS, clean shutdown, and zero stderr. -The same session configures a synthetic integrity-bound Project Index and requires Go to Definition -to select the exact physical declaration in its verified stylesheet. -It then sends a burst of full-buffer versions ending in an unknown utility, requires the protocol -to answer a formatting request before semantic publication, and accepts exactly one version-10 -`PCS001` result with the compiler's message and exact UTF-16 range. No stale semantic result may -survive the version check. The gate then starts a 30-second blocking compiler proxy, replaces that -document version, proves the stale PID exits, and requires a fresh compiler result within five -seconds. A subsequent version 13 contains an exact semantic duplicate across two -independent `pcx!` clauses; the gate requires one compiler `PCX003` mapped to the complete literal -token in the second clause. Finally, diagnostic corpus schema 2 applies twenty additional buffer -versions and compares every typed field against the CLI contract, then injects invalid compiler JSON -and the 256-literal bound to verify PCL001/PCL002. See -[the corpus contract](./lsp-diagnostic-corpus.md). - -## VS Code client candidate - -The unreleased client under `editors/vscode` follows the official VS Code language-client pattern -and selects only file-backed Rust documents. Configure `pliegocss.server.path` and -`pliegocss.compiler.path` to same-version external binaries; the extension does not download or -update either executable. Theme mode is explicitly `discover`, `seed`, or `config`. Optional -`pliegocss.projectIndex.path` forwards one exact workspace-relative or absolute index path and is -empty by default. - -The client runs in the workspace extension host, uses the first local workspace folder as process -working directory, restarts on configuration changes or through **PliegoCSS: Restart Language -Server**, and declares virtual and untrusted workspaces unsupported. Build and inspect its VSIX with: +The gate builds Rust 1.85 binaries, compares the LSP diagnostic corpus against CLI output, verifies +UTF-16 ranges, completion, hover, formatting, Project Index navigation, bounded diagnostics, current +version publication, clean shutdown, zero stderr, and zero LSP compiler processes. -```console -pnpm integration:vscode -``` +## Editor clients -That gate type-checks the client, tests fail-closed argument construction, bundles its production -entry, verifies the exact four-file extension payload, packages a VSIX below 256 KiB, and confirms -that no native server is embedded. The VSIX hash is evidence for one run, not a reproducibility -claim because the packaging tool may encode timestamps. +The VS Code manifest retains its historical compiler-path property as deprecated configuration, and +the Neovim table tolerates a legacy `compiler` member. Both clients ignore those values and launch +only the language server. `pliego-cssc` remains a separate build and CI tool. -Run the real extension-host gate with: +Run their repository gates with: ```console +pnpm integration:vscode pnpm integration:vscode-host -``` - -The first run downloads the exact VS Code 1.105.1 test runtime through the official Microsoft test -harness; later runs reuse the ignored local cache. The gate builds external Rust 1.85 server and -compiler binaries, loads the development extension in a real workspace host, follows Go to -Definition to the exact physical CSS range, edits the Rust buffer, and requires the compiler-backed -`PCS001` diagnostic followed by the exact cross-clause `PCX003` diagnostic. The extension itself -still downloads no server or compiler. - -## Neovim client candidate - -The unreleased module under `editors/neovim` uses Neovim's built-in LSP client. It requires explicit -external server/compiler paths and accepts the same discover/seed/config theme modes plus an optional -Project Index. Its `FileType` autocmd starts only for Rust buffers; invalid paths or modes fail before -spawning a server. The 3,466-byte client payload is exactly `README.md` plus -`lua/pliegocss/init.lua` and contains no download surface. - -Run the real second-editor gate with: - -```console pnpm integration:neovim ``` -The gate pins Neovim 0.12.4 and verifies the official archive SHA-256 before extraction into an -ignored test cache. Windows x64 uses `nvim-win64.zip`; Debian x86-64 uses -`nvim-linux-x86_64.tar.gz`. In both hosts Neovim opens a file-backed Rust buffer, starts the external -PliegoCSS binaries, follows Project Index definition to the physical CSS range, then observes exact -`PCS001` and cross-clause `PCX003` diagnostics. Runtime download belongs only to the test harness; -the Lua client never downloads Neovim or PliegoCSS binaries. +See [Editor setup](../getting-started/editor-setup.md) and +[the diagnostic corpus](./lsp-diagnostic-corpus.md). diff --git a/docs/reference/migration-inventory-schema-1.md b/docs/reference/migration-inventory-schema-1.md index 3ba05d7..9a3c5f8 100644 --- a/docs/reference/migration-inventory-schema-1.md +++ b/docs/reference/migration-inventory-schema-1.md @@ -1,7 +1,7 @@ # Migration inventory schema 1 Status: **Rust producer and fail-closed CLI implemented for bounded single-file Sass, Tailwind CSS -v4 entry CSS, and CSS Modules inventory; project graphs remain open** +v3/v4 entry CSS, and CSS Modules inventory; project graphs remain open** `pliego-css-source` exposes a read-only migration bridge that accepts an explicit source kind, portable logical path, and exact UTF-8 source bytes. It never runs Sass, Tailwind, PostCSS, @@ -77,15 +77,17 @@ it can prove from the supplied source and leaves execution semantics to the orig ## Covered constructs -### Tailwind CSS v4 entry CSS +### Tailwind CSS v3/v4 entry CSS +- v3 `@tailwind base`, `@tailwind components`, and `@tailwind utilities` layer directives; - `@import "tailwindcss"` and `@import "tailwindcss/preflight"`; - `@source`, including dynamic `inline(...)` candidate generation; - `@theme`, `@utility`, `@variant`, `@custom-variant`, and `@reference`; - unsupported `@apply`, `@plugin`, and `@config` seams. `preflightReliance` is `not-observed`, `implicit`, `explicit`, or `mixed`. A full -`@import "tailwindcss"` is implicit reliance; the dedicated preflight import is explicit. This +`@import "tailwindcss"` is implicit reliance; the dedicated preflight import or v3 +`@tailwind base` directive is explicit. Unknown v3 layer names are retained as unsupported. This classification does not inspect generated CSS or prove that later tooling did not remove Preflight. ### Sass/SCSS diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 96b6027..b229b1f 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -1,6 +1,6 @@ # Public API candidate -Status: **application, build-macro, and adapter surfaces published at `0.1.0-rc.2`; the +Status: **application, build-macro, and adapter surfaces prepared for `0.1.0-rc.3`; the Cargo DTCG, ownership, usage-sidecar, and application-topology collector bridges are covered by the Rust 1.85 downstream smoke; this remains a prerelease contract** @@ -215,6 +215,13 @@ attests only the declared roots and graph: it does not discover the product Carg registry, infer identities from paths, or derive bundle partitions. Scanner DTOs and standalone scanner functions remain advanced tooling rather than promoted adapter API. +Frameworks that publish `pliegors-product-topology/1` can instead use the +closed `ProductTopology::from_json` decoder and `collect_css_inputs`. The +returned `ProductCssInputs` exposes canonical reachability, a deterministic +bundle plan, and read-only `ProductBundle` IDs, source partitions, and theme +ownership. This wire consumer does not link PliegoRS types or move styling into +runtime code. + ## Migration-inventory tooling bridge Exact-version audit and migration tooling may use the selected `pliego-css-source` inventory @@ -345,6 +352,10 @@ They expose `pub` items because sibling packages need them. Their mutable IR, pa catalog model, binary encoder/decoder, scanner DTOs/functions, and composition helpers are not covered by the minimal `0.1.x` application promise unless a later RC document explicitly promotes an item. +The G2 `CompileRequest`, `CompileResult`, `AnalysisHost`, `PhysicalRulePlanner`, and `PcxRequest` +surfaces are likewise exact-version advanced APIs shared by the packaged CLI, watch, macro, scanner, +and LSP adapters. Their architectural role is documented and tested; they are not yet a stable +third-party library promise. See [Shared compiler engine](./engine-api.md). Persisted bytes and schemas named above remain versioned regardless of this Rust-API classification. ## Change policy diff --git a/docs/reference/reversible-migration-plan-schema-1.md b/docs/reference/reversible-migration-plan-schema-1.md index 051d4fa..96121e3 100644 --- a/docs/reference/reversible-migration-plan-schema-1.md +++ b/docs/reference/reversible-migration-plan-schema-1.md @@ -39,3 +39,9 @@ proposal only when explicitly invoked. Apply refuses existing output/receipt pat receipt bound to exact output bytes. Rollback refuses drift and removes the receipt only after the sidecar is safely removed. This is still not a Tailwind codemod: authored sources and templates stay untouched, and the plan's executable edit set remains empty. + +G6 does not change that command's schema or read-only promise. The separate +[`adapter coexistence protocol`](./adapter-coexistence.md) composes compiler-manifest mappings with +an explicitly authored `migration-group-apply` manifest for literal complete class groups. That +bounded protocol has browser and exact-rollback evidence; it does not silently populate or execute +the inventory-only plan's `edits` field. diff --git a/docs/reference/semantic-ir-binary-v2.md b/docs/reference/semantic-ir-binary-v2.md index fc3aacf..70e46cb 100644 --- a/docs/reference/semantic-ir-binary-v2.md +++ b/docs/reference/semantic-ir-binary-v2.md @@ -1,6 +1,6 @@ # Semantic IR binary format 2 -Status: **machine-enforced prerelease contract at `0.1.0-rc.2`** +Status: **machine-enforced prerelease contract at `0.1.0-rc.3`** Format 2 is the current persisted, provenance-bearing semantic IR envelope exposed by `pliego-css-compiler`. It is distinct from [StyleId format 2](./style-id-format-v2.md): StyleId is a diff --git a/docs/reference/standards-provenance.md b/docs/reference/standards-provenance.md index c4ebbaf..5e6fe12 100644 --- a/docs/reference/standards-provenance.md +++ b/docs/reference/standards-provenance.md @@ -27,10 +27,10 @@ The root has exactly `schemaVersion` (`1`) and `sources`. Every source has exact | `kind` | `software`, `dataset`, or `specification`. | | `version` | Exact numeric or SemVer-like version; no ranges or moving tags. | | `status` | Exact publication/package status. | -| `license` | Reviewed license identifier. | +| `license` | Reviewed license identifier, including Apache-2.0, ISC, MIT, MPL-2.0, and the named W3C licenses. | | `url` | Canonical, credential-free HTTPS source; unique. | -| `relationship` | Why PliegoCSS records the source. | -| `usage` | `build-time`, `frozen-data`, `implemented-contract`, `audit-reference`, or `benchmark-only`. | +| `relationship` | Why PliegoCSS records the source, including the isolated browser-certification toolchain. | +| `usage` | `build-time`, `frozen-data`, `implemented-contract`, `audit-reference`, `benchmark-only`, or `test-only`. | | `evidence[]` | Existing repository-relative files that prove the relationship. | | `integrity[]` | Unique subject plus reviewed npm SRI, SHA-256 metadata, or Cargo checksum; empty only for versioned specifications. | @@ -49,7 +49,10 @@ benchmark artifact. | WCAG | `2.2` Recommendation | Authoritative accessibility reference; a static PliegoCSS pass is not a WCAG conformance claim. | | CSS Color Adjustment | Level 1 Candidate Recommendation Snapshot | Forced-colors reference. | | Media Queries | Level 5 Working Draft | User-preference media-query reference; draft status is explicit. | -| Tailwind CSS and CLI | `4.3.2`, MIT | Performance/ergonomics baseline only; package SRI plus the full transitive pnpm lock SHA-256 are frozen. It is not a PliegoCSS runtime or backend dependency. | +| Tailwind CSS and CLI | current `4.3.3`, upstream `v3-lts` `3.4.19`, frozen `4.3.2`; MIT | Benchmark Authority v2 lanes only; package SRI plus the full transitive pnpm lock SHA-256 are frozen. They are not PliegoCSS runtime or backend dependencies. | +| Playwright | `1.61.1`, Apache-2.0 | Test-only Chromium, Firefox, and WebKit orchestration for Browser/output certification v1; `playwright-core` is pinned too. | +| pixelmatch | `7.2.0`, ISC | Test-only bounded perceptual PNG comparison; the original artifact hashes are retained. | +| pngjs | `7.0.0`, MIT | Test-only PNG decoding and diff encoding for certification evidence. | The DTCG status comes from the official [Format](https://www.w3.org/community/reports/design-tokens/CG-FINAL-format-20251028/), [Color](https://www.w3.org/community/reports/design-tokens/CG-FINAL-color-20251028/), and diff --git a/docs/reference/style-id-format-v2.md b/docs/reference/style-id-format-v2.md index fd216c3..be64793 100644 --- a/docs/reference/style-id-format-v2.md +++ b/docs/reference/style-id-format-v2.md @@ -1,6 +1,6 @@ # StyleId binary format 2 -Status: **implemented and protected by exact prerelease vectors at `0.1.0-rc.2`** +Status: **implemented and protected by exact prerelease vectors at `0.1.0-rc.3`** This document specifies the canonical byte stream used by `STYLE_ID_FORMAT_VERSION = 2`, the derivation of the 128-bit `StyleId`, and its relationship to a @@ -45,7 +45,7 @@ their raw bytes, and removes exact duplicate records. The resulting order and co Authoring order, assignment-table order, and duplicate provenance therefore do not affect identity. The explicit theme-format field prevents the same 128 theme bits from being interpreted under an -unreported ThemeId contract. The current stream embeds `THEME_ID_FORMAT_VERSION = 2` and the +unreported ThemeId contract. The current stream embeds `THEME_ID_FORMAT_VERSION = 3` and the theme binary format. ## Assignment record @@ -208,9 +208,9 @@ Current frozen examples are: | Theme/style | StyleId | Class | |---|---|---| -| Seed theme, `flex gap-4` | `70cb04ef9bf9621f5826351f1778f68e` | `pc_6oe73ec16rbb7ublcoa3bpzf2` | -| Minimal compatibility theme, `flex gap-gutter tablet:grid` | `e0b572e3fdfbf091d9a2ddb126278634` | `pc_dax2y1pql4op1rjk97yv9e88k` | -| Seed theme, `container-inline cq-sm:grid` | `11893074aaaf88f7b356ad6d53a913e2` | `pc_11dgqogrs5aq1d9ocwyulg6w2` | +| Seed theme, `flex gap-4` | `fe3a92576be2bb53e3240249bc45b829` | `pc_f1u1l7d58kemkdqdjie56hdex` | +| Minimal compatibility theme, `flex gap-gutter tablet:grid` | `b742ceb589d4f412c6ba77e81f632f53` | `pc_aukxxmkm8bmauj8duf8zpjdcj` | +| Seed theme, `container-inline cq-sm:grid` | `78f8ca7e9a1d8b7737ee243d87faf881` | `pc_75tq511l3l9q0mx595ykqafsx` | ## Document versions @@ -220,14 +220,14 @@ Artifacts that expose identity now report all three related versions at the top { "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2 + "themeIdFormatVersion": 3 } ``` -These fields are required in CSS manifest schemas 3, 4, and 5, inspection schema 2, catalog schema 3, +These fields are required in CSS manifest schemas 3, 4, and 5, inspection schema 3, catalog schema 4, and explain schema 2. They let a consumer reject mixed identity contracts before interpreting a `styleId` or class. Structured diagnostics remain schema 1, the theme configuration remains schema -1, the theme identity and binary remain format 1, declarative bundle plans use schemas 1/2, +1, the theme identity and binary use format 3, declarative bundle plans use schemas 1/2, the reachability sidecar is schema 1, nested manifest graphs are schemas 1/2, and the PliegoRS source-surface contract remains schema 2. diff --git a/docs/reference/theme-schema.md b/docs/reference/theme-schema.md index a8aee48..80ab148 100644 --- a/docs/reference/theme-schema.md +++ b/docs/reference/theme-schema.md @@ -106,12 +106,17 @@ tablet = "52rem" # adds a new responsive variant Accepted units are `px`, `rem`, `em`, `ch`, and `vw`. Units are lowercase and a value must be a finite number greater than zero. Percentages, unitless values, media-query expressions, and values -such as `0rem` are rejected. +such as `0rem` are rejected. Every active breakpoint must use the same unit. Mixed units are rejected +because their runtime order depends on font, container, or viewport state and cannot produce one +deterministic narrow-to-wide cascade. New breakpoint IDs are assigned deterministically after the seed IDs. Duplicate names or IDs are fatal registry errors. A custom breakpoint cannot use a built-in condition name such as `hover`, `focus`, `dark`, `motion-safe`, or `placeholder`; configuration rejects the collision instead of -changing the meaning of that variant. Seed breakpoint names such as `sm` may be overridden. +changing the meaning of that variant. Seed breakpoint names such as `sm` may be overridden. Stable +IDs are lookup identity only. A separate, unique `cascade_rank` is derived from the numeric minimum +width and controls media/container emission from narrowest to widest; equal widths use canonical +name/ID tie-breaking. Catalog schema 4 and inspection schema 3 expose this rank explicitly. ## Build artifact and identity @@ -122,12 +127,13 @@ lowercase `ThemeId`, so a format migration cannot alias an older artifact. The b magic header, format version, size limits, UTF-8 validation, registry validation, and a stored-ID check when decoded. -ThemeId format 2 hashes an explicitly domain-separated and length-framed canonical stream with +ThemeId format 3 hashes an explicitly domain-separated and length-framed canonical stream with SHA-256. The first 16 digest bytes, interpreted big-endian, form the `ThemeId` (with all-zero remapped to one). Token and breakpoint records include explicit section/record tags, counts, fixed-width IDs, -and UTF-8 byte lengths. Input definitions are sorted before encoding, so order does not affect the -stream; any canonical value change does. Because the stored identity semantics changed, the current -theme binary is format 2. Format-1 binaries are rejected explicitly and must be regenerated. +breakpoint cascade ranks, and UTF-8 byte lengths. Input definitions are sorted before encoding, so +order does not affect the stream; any canonical value change does. Because breakpoint order is now +part of stored identity, the current theme binary is format 3. Older binaries are rejected +explicitly and must be regenerated. The decoder rejects artifacts larger than 16 MiB, more than 65,536 entries in either the token or breakpoint list, and names or values larger than 1 MiB each. It also rejects truncation, unknown diff --git a/docs/reference/utilities.md b/docs/reference/utilities.md index f1effa9..662db79 100644 --- a/docs/reference/utilities.md +++ b/docs/reference/utilities.md @@ -264,7 +264,7 @@ uses the first 128 digest bits in big-endian order. UTF-8 content, not intern-ta `Debug` output, enters the stream. The class encoder remains format 1, but candidate format-1 class strings changed because their StyleId input changed. See the exact [StyleId format-2 contract](./style-id-format-v2.md). These vectors are machine-enforced in -public preview `0.1.0-rc.2`; final `0.1.0` promotion remains open. `Style::EMPTY.class_name()` returns an empty +public-preview candidate `0.1.0-rc.3`; final `0.1.0` promotion remains open. `Style::EMPTY.class_name()` returns an empty string and does not represent an emitted rule. Run the checked Rust example and compile its CSS from the workspace root: diff --git a/docs/releases/0.1.0-rc.1.md b/docs/releases/0.1.0-rc.1.md index d23abf5..68a9ffc 100644 --- a/docs/releases/0.1.0-rc.1.md +++ b/docs/releases/0.1.0-rc.1.md @@ -1,5 +1,8 @@ # PliegoCSS 0.1.0-rc.1 +> Historical release record. It has no current promotion authority. See the +> schema-3 [machine readiness record](../product/release-readiness-0.1.0.json). + The first PliegoCSS release candidate established the standards-first CSS compiler, verifier, and evidence pipeline written in Rust. It supported ordinary CSS adoption alongside an optional compile-time typed Rust frontend. diff --git a/docs/releases/0.1.0-rc.2.md b/docs/releases/0.1.0-rc.2.md index 8725043..18c7a91 100644 --- a/docs/releases/0.1.0-rc.2.md +++ b/docs/releases/0.1.0-rc.2.md @@ -1,5 +1,9 @@ # PliegoCSS 0.1.0-rc.2 +> Historical release record. It describes the published RC.2 tag but has no +> current promotion authority. See the schema-3 +> [machine readiness record](../product/release-readiness-0.1.0.json). + The second PliegoCSS release candidate is a standards-first CSS compiler, verifier, and evidence pipeline written in Rust. It supersedes the historical `rc.1` tag with corrected release provenance, a coordinated crates.io unit, diff --git a/docs/releases/0.1.0-rc.3.md b/docs/releases/0.1.0-rc.3.md new file mode 100644 index 0000000..b8dc40a --- /dev/null +++ b/docs/releases/0.1.0-rc.3.md @@ -0,0 +1,60 @@ +# PliegoCSS 0.1.0-rc.3 + +> Candidate release record. Publication is not authorized by this document. +> The schema-3 [machine readiness record](../product/release-readiness-0.1.0.json) +> remains the current promotion authority. + +RC.3 consolidates the post-RC.2 verification, distribution, and adapter work +into the next exact-version nineteen-crate compatibility unit. + +## Highlights + +- A shared pure `CompileRequest` / `CompileResult` engine across CLI, watch, + macros, source scanning, and LSP. +- LSP semantic analysis without compiler child processes or synthetic source + files. +- ThemeId and theme binary format 3, catalog schema 4, and inspection schema 3 + with explicit regeneration requirements. +- Benchmark Authority v2 against Tailwind CSS 4.3.3, Tailwind v3 LTS 3.4.19, + and the frozen 4.3.2 regression lane. +- Browser/output certification contracts for Chromium, Firefox, and WebKit on + Windows x64, Linux x64, and macOS ARM64. +- A repository-only native CLI/LSP distribution with SHA-256 manifests, + CycloneDX SBOMs, GitHub/Sigstore attestations, and a no-lifecycle pnpm + package that is never published to npmjs. +- Bounded Tailwind coexistence certification across 21 HTML, Vite, and + PliegoRS project snapshots with exact rollback. +- Governed external-adoption intake that remains blocked until real reviewed + interviews, incidents, and pilots satisfy G7. +- Framework-neutral consumption of the canonical + `pliegors-product-topology/1` snapshot, keeping PliegoRS topology and + PliegoCSS reachability/bundle planning aligned without a runtime dependency. + +## Compatibility + +The candidate preserves StyleId format 2, class-name format 1, and semantic IR +binary format 2. ThemeId and theme binary format move to 3. Consumers must +regenerate theme binaries, macro output, CSS, manifests, catalogs, indexes, +receipts, and class-keyed caches as one coordinated revision. + +## Install + +These commands become valid only after all nineteen crates are published and +the registry replay passes: + +```console +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked +cargo install pliego-css-lsp --version '=0.1.0-rc.3' --locked +``` + +## Open gates + +- exact-candidate hosted CI and CodeQL; +- complete hosted browser and adapter matrices; +- crates.io and production replay; +- signed repository release assets; +- external G7 adoption evidence; and +- explicit publication authorization. + +No local result, historical artifact, or this release note can clear those +source-bound gates. diff --git a/docs/site/audit-and-transform.md b/docs/site/audit-and-transform.md new file mode 100644 index 0000000..86b53bf --- /dev/null +++ b/docs/site/audit-and-transform.md @@ -0,0 +1,24 @@ + + +# Standards-first, not framework-first. + +Analyze standard CSS, enforce policy, and transform output without claiming ownership of authored source. + +## Bounded classification {#classifier} + +The classifier recognizes deliberate standard-CSS surfaces and reports unsupported constructs instead of guessing. A versioned corpus controls the claim. + +## Deterministic transformation {#transform} + +Target browsers, minify, and check output drift. Add --check in CI to remain read-only. + +```console +pliego-cssc transform-css --input app.css \ + --output dist/app.css --targets modern --format minified +``` + +## Compatibility, accessibility, and budgets {#policy} + +Policy documents bind findings to explicit targets and limits. The same canonical findings feed terminals, JSON automation, and SARIF. diff --git a/docs/site/cascade.md b/docs/site/cascade.md new file mode 100644 index 0000000..947f208 --- /dev/null +++ b/docs/site/cascade.md @@ -0,0 +1,24 @@ + + +# Explain the winning declaration. + +Inspect authored CSS by element and longhand to see matching selectors, precedence, and the final winner. + +## Ask one precise question {#query} + +Provide a CSS file, a bounded element description, and one longhand. The command reports candidates instead of simulating an entire browser. + +```console +pliego-cssc explain-cascade --input app.css \ + --element 'button#save.action' --property background-color +``` + +## See why it won {#winner} + +Origin, importance, layer, specificity, and source position are surfaced in comparison order with a stable machine-readable form. + +## Keep the model honest {#boundary} + +This is a static bounded inspector. Runtime DOM state, animation interpolation, adopted sheets, and script mutation remain browser responsibilities. diff --git a/docs/site/composition.md b/docs/site/composition.md new file mode 100644 index 0000000..1763821 --- /dev/null +++ b/docs/site/composition.md @@ -0,0 +1,32 @@ + + +# Compose by meaning, not source order. + +Compatible slots commute. Conflicting slots require an explicit branch-over-base operation. + +## Equivalent input, equivalent identity {#commute} + +Utilities that occupy independent semantic slots canonicalize to the same identity even when authored in a different order. + +```text +flex gap-4 p-6 +p-6 flex gap-4 +``` + +## Ambiguous lists are rejected {#conflict} + +p-4 p-6 does not mean last one wins. Both declarations claim the same slot under the same condition, so compilation stops. + +```console +pliego-cssc check --style "p-4 p-6" --seed +``` + +## Overrides are explicit operations {#override} + +When an application intentionally replaces one semantic assignment with another, use composition so the override relationship remains visible and inspectable. + +```console +pliego-cssc compile --compose "p-4" "p-6" --seed +``` diff --git a/docs/site/control-artifacts.md b/docs/site/control-artifacts.md new file mode 100644 index 0000000..2aa69ea --- /dev/null +++ b/docs/site/control-artifacts.md @@ -0,0 +1,29 @@ + + +# Publish the receipt last. + +Controlled builds stage related outputs, lock the destination, verify hashes, compensate failures, and expose one completed receipt. + +## One rollback-capable group {#group} + +CSS, source map, style manifest, Token Graph, findings, control manifest, and receipt are generated from one exact input snapshot. + +```console +pliego-cssc compile --source src --output dist/app.css \ + --manifest dist/app.manifest.json --control-dir dist +``` + +## Check without writing {#check} + +The same command with --check recomputes every artifact and fails on byte drift without taking publication locks or replacing files. + +```console +pliego-cssc compile --source src --output dist/app.css \ + --manifest dist/app.manifest.json --control-dir dist --check +``` + +## Completion is explicit {#receipt} + +Consumers treat the fixed receipt as the publication boundary. Its absence means the group is incomplete, even if individual files are present. diff --git a/docs/site/critical-css.md b/docs/site/critical-css.md new file mode 100644 index 0000000..6382a90 --- /dev/null +++ b/docs/site/critical-css.md @@ -0,0 +1,19 @@ + + +# Prove what rendered above the fold. + +Join observed browser behavior to project topology and retained style identity without letting an observation silently authorize removal. + +## Observe a real route {#observe} + +Capture viewport, route, interaction state, loaded assets, matched styles, and source identities from a browser run against the built application. + +## Join exact universes {#join} + +Evidence is accepted only when its project index, asset plan, reachability, and CSS identities match the build under review. + +## Observation does not equal deletion {#authority} + +A style unseen in one scope remains unknown outside that scope. Explicit retention and reachability policies control removal authority. diff --git a/docs/site/editor-setup.md b/docs/site/editor-setup.md new file mode 100644 index 0000000..4bcc9d2 --- /dev/null +++ b/docs/site/editor-setup.md @@ -0,0 +1,27 @@ + + +# Put the compiler engine in the feedback loop. + +Use `pliego-css-lsp` for diagnostics, completion, hover evidence, and catalog-aware authoring without launching the build CLI. + +## VS Code extension {#vscode} + +The maintained extension launches `pliego-css-lsp`, discovers the project theme, and renders diagnostics in Rust source. + +```console +pnpm --filter pliegocss-vscode package +``` + +## Any LSP client {#generic} + +Launch the server over stdio from the same workspace used by CI. The protocol stays editor-neutral. + +```console +pliego-css-lsp --seed +``` + +## Bound in-process work {#safety} + +Document versions debounce for 150 ms, stale results are suppressed, semantic caches are bounded, and each document stops at 256 checks with `PCL002`. diff --git a/docs/site/evidence.md b/docs/site/evidence.md new file mode 100644 index 0000000..84a6e9f --- /dev/null +++ b/docs/site/evidence.md @@ -0,0 +1,24 @@ + + +# A green build should explain itself. + +Artifacts carry hashes, source lineage, graphs, findings, and receipts designed for replay. + +## Canonical manifests {#manifest} + +Style identity, class identity, theme identity, origins, conditions, physical declarations, and final CSS hashes live in a deterministic document. + +```console +pliego-cssc compile --source src --seed --theme \ + --output dist/app.css --manifest dist/app.manifest.json +``` + +## Receipt-last publication {#receipts} + +Controlled operations stage output, lock the destination, verify inputs, publish artifacts, and write the receipt last. + +## Measured, inherited, pending, uncertain {#benchmarks} + +Release readiness preserves the evidence class. Old hosted evidence never silently passes changed source. diff --git a/docs/site/external-adoption.md b/docs/site/external-adoption.md new file mode 100644 index 0000000..250b88d --- /dev/null +++ b/docs/site/external-adoption.md @@ -0,0 +1,23 @@ + + +# External adoption is a measured gate. + +G7 is currently blocked. Owned fixtures and synthetic incidents cannot satisfy it. + +## Qualified evidence {#status} + +Interviews: 0/10. Real incidents: 0/20. Completed pilots: 0/3. Independent projects: 0/3. Independent organizations: 0/3. + +## Applications are open {#apply} + +Read the [public cohort notice](https://github.com/celiumsai/pliegocss/issues/7) or apply privately at [hello@pliegocss.dev](mailto:hello@pliegocss.dev?subject=G7%20external%20adoption). Do not post names, email addresses, credentials, private repository URLs, proprietary source, transcripts, or incident material on GitHub. A public application is not consent and is not evidence. + +## Admission boundary {#boundary} + +Only consented, redacted, reviewed external records bound to private source evidence by SHA-256 count. Projects owned by Celiums Solutions, PliegoCSS, or celiumsai do not count. + +## Certified adapter scope {#support} + +The frozen 0.1.x policy covers complete literal class groups in static HTML, Vite 8.1.5, and pinned PliegoRS rendered output against Tailwind CSS 3.4.19 or 4.3.3. Tailwind stays loaded during coexistence. Dynamic classes, arbitrary config/plugin execution, and Tailwind removal are excluded. diff --git a/docs/site/first-audit.md b/docs/site/first-audit.md new file mode 100644 index 0000000..7738c4a --- /dev/null +++ b/docs/site/first-audit.md @@ -0,0 +1,28 @@ + + +# Learn before changing. + +Run a read-only CSS audit, inspect canonical findings, then decide whether transformation or policy is justified. + +## Read the first report {#human} + +Human output is optimized for local diagnosis. Every finding carries a stable code and source location rather than an opaque score. + +```console +pliego-cssc audit --input app.css \ + --targets baseline-widely --format human +``` + +## Feed automation safely {#automation} + +Canonical JSON and SARIF preserve the same finding model for CI, code scanning, and archival evidence. + +```console +pliego-cssc audit --input app.css --targets modern --format sarif > findings.sarif +``` + +## Unknown means unknown {#unknown} + +The standard-CSS classifier is bounded. Unsupported selectors, properties, or values fail closed under compatibility policy rather than being treated as safe. diff --git a/docs/site/getting-started.md b/docs/site/getting-started.md new file mode 100644 index 0000000..0cc3da6 --- /dev/null +++ b/docs/site/getting-started.md @@ -0,0 +1,36 @@ + + +# Start with the CSS you already have. + +Audit first, compile when it adds value, and keep the browser contract ordinary. + +## Install the release candidate {#install} + +The 0.1.0-rc.3 compatibility unit is under exact-source verification. Use this command after all nineteen crates are published and replayed with Rust 1.85. + +```console +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked +``` + +## Audit ordinary CSS {#audit} + +Start without changing authoring. The analyzer emits human, canonical JSON, or SARIF findings and fails closed on unknown surfaces. + +```console +pliego-cssc audit --input app.css \ + --targets baseline-widely --format human +``` + +## Add typed identities selectively {#compile} + +Rust literals are validated at compile time and compile to static standards-compliant CSS. There is no styling runtime. + +```rust +use pliego_css::{pc, Style}; + +const BUTTON: Style = pc!( + "inline-flex gap-2 rounded-lg bg-accent px-4 py-2" +); +``` diff --git a/docs/site/installation.md b/docs/site/installation.md new file mode 100644 index 0000000..02bdee3 --- /dev/null +++ b/docs/site/installation.md @@ -0,0 +1,29 @@ + + +# Choose an adoption surface. + +Install the CLI for any project, add the Rust crates only when typed authoring belongs in the application. + +## CLI-only adoption {#cli} + +The command-line compiler can audit, transform, explain, bundle, and migrate standard CSS without adding a runtime dependency to the application. + +```console +cargo install pliego-cssc --version '=0.1.0-rc.3' --locked +pliego-cssc --version +``` + +## Typed Rust authoring {#rust} + +Add pliego-css and its macro crate to applications that want compile-time style identities. The emitted result remains ordinary CSS. + +```console +[dependencies] +pliego-css = "=0.1.0-rc.3" +``` + +## Pin the exact release identity {#pin} + +Before the public release, use the signed project tag plus Cargo.lock. Do not track a moving branch in a production build. diff --git a/docs/site/integrations/build-tools.md b/docs/site/integrations/build-tools.md new file mode 100644 index 0000000..99b7c6e --- /dev/null +++ b/docs/site/integrations/build-tools.md @@ -0,0 +1,26 @@ + + +# Integrate through files, not hidden hooks. + +Vite, webpack, esbuild, CI systems, and custom pipelines consume explicit CSS, manifests, findings, maps, and asset plans. + +## Run PliegoCSS as a producer {#producer} + +Invoke compile or transform before the application bundler and pass its ordinary CSS output through the normal asset graph. + +```text +"scripts": { + "css": "pliego-cssc compile --source src --output dist/pliego.css", + "build": "npm run css && vite build" +} +``` + +## Consume stable sidecars {#consumer} + +Use the numbered JSON schemas for manifests, Token Graphs, findings, reachability, project indexes, and Asset Plans instead of scraping terminal text. + +## Keep topology in its owner {#ownership} + +A framework adapter may export route and component reachability. PliegoCSS validates and projects that sidecar but does not crawl application internals. diff --git a/docs/site/integrations/plain-html.md b/docs/site/integrations/plain-html.md new file mode 100644 index 0000000..72d78f5 --- /dev/null +++ b/docs/site/integrations/plain-html.md @@ -0,0 +1,28 @@ + + +# Audit and transform without a framework. + +Use PliegoCSS as a standards control layer around an ordinary stylesheet and static HTML build. + +## Start with emitted CSS {#audit} + +No adapter is required. Audit the exact stylesheet the browser loads and archive canonical findings beside the build. + +```console +pliego-cssc audit --input public/app.css --targets baseline-widely +``` + +## Transform explicitly {#transform} + +Compatibility transformation and minification are separate from analysis so a read-only audit never rewrites authored output. + +```console +pliego-cssc transform-css --input src/app.css \ + --output public/app.css --targets modern --format minified +``` + +## Typed authoring is optional {#typed} + +Rust applications can compile typed styles into the same static site, but plain HTML consumers only need the final CSS asset. diff --git a/docs/site/integrations/pliegors.md b/docs/site/integrations/pliegors.md new file mode 100644 index 0000000..3157d10 --- /dev/null +++ b/docs/site/integrations/pliegors.md @@ -0,0 +1,23 @@ + + +# Native together. Independent by contract. + +PliegoRS is the first native consumer, not the only target. It supplies topology; PliegoCSS supplies style identity and artifacts. + +## Explicit ownership {#boundary} + +PliegoRS owns routes, pages, assets, and browser lifecycle. PliegoCSS owns parsing, semantic conflicts, CSS, manifests, and policy findings. + +## Route and island bundles {#bundles} + +A reachability sidecar projects application-owned topology into selected CSS bundles without PliegoCSS pretending to understand the framework. + +## Replayed from published crates {#proof} + +The maintained fixture locks PliegoRS 0.0.2 registry packages and verifies the remote-reachable framework revision separately. + +```console +PLIEGORS_ROOT=/path/to/pliegors pnpm integration:pliegors +``` diff --git a/docs/site/manifests.md b/docs/site/manifests.md new file mode 100644 index 0000000..e1df74e --- /dev/null +++ b/docs/site/manifests.md @@ -0,0 +1,24 @@ + + +# Trace source intent into emitted bytes. + +Manifest schema 5 connects source origins, semantic assignments, final selectors, physical declarations, and CSS ranges. + +## Identity chain {#identity} + +StyleId identifies canonical semantics. Class identity, theme identity, configuration, and final CSS hash bind that style into one build. + +```console +pliego-cssc compile --source src --manifest dist/app.manifest.json \ + --manifest-version 5 --output dist/app.css +``` + +## Physical lineage {#physical} + +Generated custom properties and synthesized declarations retain contributors, importance, conditions, selector, property, value, and output byte range. + +## Request the schema you consume {#versions} + +Schema 3 supports style manifests, schema 4 adds reachability ownership, and schema 5 adds closed physical tracing. Consumers must pin their contract. diff --git a/docs/site/migration.md b/docs/site/migration.md new file mode 100644 index 0000000..7507e3f --- /dev/null +++ b/docs/site/migration.md @@ -0,0 +1,23 @@ + + +# Adopt without surrendering the exit. + +Inventory first, generate bounded plans, preserve originals, and detect drift immediately before publication. + +## Read-only discovery {#inventory} + +Tailwind, Sass, CSS Modules, and standard CSS can be inventoried before any replacement plan exists. + +```console +pliego-cssc migration-project-plan ./migration-declaration.json +``` + +## Reversible replacements {#reversible} + +Plans bind source and destination hashes. Apply and rollback use adjacent locks, pre-publication revalidation, backups, and group compensation. + +## No speculative codemods {#limits} + +Unsupported or context-dependent constructs stay in the inventory. The migration layer does not infer framework semantics it cannot prove. diff --git a/docs/site/migration/css-modules.md b/docs/site/migration/css-modules.md new file mode 100644 index 0000000..a6d19a4 --- /dev/null +++ b/docs/site/migration/css-modules.md @@ -0,0 +1,23 @@ + + +# Preserve component ownership. + +Discover module exports and usage while keeping application-owned component topology separate from CSS semantics. + +## Inventory exported classes {#inventory} + +The scanner identifies bounded module class definitions and their source locations without assuming a particular bundler naming strategy. + +```console +pliego-cssc migration-inventory css-modules src/Card.module.css +``` + +## Let the application own reachability {#ownership} + +Route and component reachability comes from the framework or product index, not from filename heuristics inside the CSS compiler. + +## Mixed adoption is first-class {#mixed} + +A project can retain modules for component styles, audit their emitted CSS, and introduce typed identities only for new or shared surfaces. diff --git a/docs/site/migration/sass.md b/docs/site/migration/sass.md new file mode 100644 index 0000000..d6a8c73 --- /dev/null +++ b/docs/site/migration/sass.md @@ -0,0 +1,23 @@ + + +# Separate syntax conversion from semantic adoption. + +Inventory variables, nesting, mixins, imports, and generated surfaces without pretending every Sass abstraction is a utility. + +## Map the source surface {#inventory} + +The inventory records statically visible Sass roles and unresolved dynamic behavior so the team can choose what stays authored CSS. + +```console +pliego-cssc migration-inventory sass styles/main.scss +``` + +## Ordinary CSS is a valid destination {#css-first} + +Use the standard-CSS transformer for syntax and compatibility work. Adopt typed PliegoCSS styles only where identity and provenance pay for themselves. + +## Keep originals and rollback evidence {#rollback} + +Applied replacements preserve source backups and hash-bound rollback records. A changed destination aborts rather than overwriting concurrent edits. diff --git a/docs/site/migration/tailwind.md b/docs/site/migration/tailwind.md new file mode 100644 index 0000000..762ebf5 --- /dev/null +++ b/docs/site/migration/tailwind.md @@ -0,0 +1,27 @@ + + +# Inventory Tailwind before translating it. + +Recover the real utility surface, classify supported roles, and keep unsupported framework behavior visible. + +## Scan without replacement {#inventory} + +The inventory recognizes bounded class-bearing surfaces and reports candidates with source ranges. It does not rewrite template semantics during discovery. + +```console +pliego-cssc migration-inventory tailwind src/App.tsx +``` + +## Generate a hash-bound project plan {#plan} + +A declaration selects roots and destinations. The plan records exact source and output hashes before any replacement can be applied. + +```console +pliego-cssc migration-project-plan migration.json +``` + +## Preserve unsupported behavior {#gaps} + +Plugins, generated variants, arbitrary selectors, and runtime class construction remain explicit migration findings unless a project-owned adapter proves them. diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md new file mode 100644 index 0000000..3b6e8a0 --- /dev/null +++ b/docs/site/reference/configuration.md @@ -0,0 +1,19 @@ + + +# Configuration is part of identity. + +Theme, target, format, manifests, reachability, pruning, and token selections are explicit, validated, and hash-bound. + +## Conventional TOML discovery only {#discovery} + +Without --config, --tokens, or --seed, the CLI searches for pliego.theme.toml from the workspace and input package roots. Ambiguous candidates fail. + +## Selectors are mutually exclusive {#exclusive} + +--config, --tokens, and --seed cannot be combined. JSON token documents are never discovered implicitly. + +## Bind the complete choice {#hash} + +Targets, format, emission settings, manifest version, pruning, theme bytes, resolver selections, and reachability bytes participate in the control configuration hash. diff --git a/docs/site/reference/diagnostics.md b/docs/site/reference/diagnostics.md new file mode 100644 index 0000000..b4e96b8 --- /dev/null +++ b/docs/site/reference/diagnostics.md @@ -0,0 +1,23 @@ + + +# Stable codes, precise ranges. + +Parser, source scanner, composition, policy, invocation, migration, and formatter failures share a canonical transport. + +## Human diagnostics {#human} + +Terminal output favors the next corrective action while retaining the stable code, severity, file, line, column, and byte range. + +## Canonical JSON {#json} + +JSON mode writes one schema-1 document to stderr on failure and leaves stdout empty. Consumers should branch on codes, not localized prose. + +```console +pliego-cssc --diagnostic-format json check --style "p-4 p-6" --seed +``` + +## SARIF for code scanning {#sarif} + +Audit findings can be serialized as SARIF while preserving canonical rule identity and source regions for hosted review systems. diff --git a/docs/site/release-readiness.md b/docs/site/release-readiness.md new file mode 100644 index 0000000..f7c8ba3 --- /dev/null +++ b/docs/site/release-readiness.md @@ -0,0 +1,19 @@ + + +# Release truth bound to one exact candidate. + +The current 1.0 promotion state is blocked; no historical report can override these source-bound gates. + +## Candidate identity {#candidate} + +Target 0.1.0; candidate 0.1.0-rc.2; tag v0.1.0-rc.2; commit 064dcbce96a3a5cc97a940d07566c008bb5e2d3e; Git tree 010a64f5d0bbd142a1d015fda4ccf573cc6e4133; observed 2026-07-22T14:10:00Z; expires 2026-07-29T14:10:00Z. + +## Readiness dimensions {#dimensions} + +Technical: blocked; Operational: blocked; Adoption: blocked; Authorized: blocked; Promotion: blocked. + +## Current blockers {#blockers} + +exact-candidate-ci (pending): GitHub Actions run 29864046865 passed the exact RC.2 commit across Ubuntu, Windows, and macOS with Rust 1.85 and 1.96 plus the listed release jobs, but its promotion window has expired and requires a fresh candidate replay. exact-candidate-codeql (pending): CodeQL run 29864046876 passed Actions, JavaScript/TypeScript, and Rust analysis on the exact RC.2 commit, but its promotion window has expired and requires a fresh candidate replay. g0-corrections-on-candidate (pending): G0 corrections are not part of the exact RC.2 candidate. They require a newly named candidate plus hosted replay before they can become release evidence. browser-release-matrix (blocked): Every required hosted browser lane is not configured for the exact candidate source; local or historical browser runs have no promotion authority. adapter-coexistence-matrix (blocked): RC.2 predates the G6 adapter authority. It has no clean exact-source 21-project HTML/Vite/PliegoRS matrix, Tailwind v3/v4 source/output audit, DOM/ARIA/style equivalence, exact rollback, or pinned PliegoRS framework replay. registry-replay-refresh (pending): RC.2 was previously replayed from crates.io, but schema 3 requires a fresh, unexpired, artifact-hashed replay for the exact promotion candidate. production-deployment-refresh (pending): The production site has historical deployment evidence, but a current exact-source edge and browser replay with hashed artifacts is missing. signed-binary-distribution (blocked): RC.2 predates the repository-only G5 distribution contract. It has no exact-source native archives, checksums, CycloneDX SBOMs, GitHub/Sigstore attestations, immutable GitHub Release, or verified pnpm package asset; npmjs publication is forbidden. external-adoption-evidence (pending): RC.2 predates the G7 authority. Recruitment is open, but the required external interviews, real incidents, and pilots have not been recorded as consented, redacted, reviewed, hash-bound evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree. diff --git a/docs/site/standard-css/policies.md b/docs/site/standard-css/policies.md new file mode 100644 index 0000000..a1b4c46 --- /dev/null +++ b/docs/site/standard-css/policies.md @@ -0,0 +1,28 @@ + + +# Make browser support and budgets executable. + +Compatibility, accessibility, payload, package, route, and ownership policies turn expectations into reviewable inputs. + +## Version the target {#compatibility} + +baseline-widely and modern are frozen policy profiles with explicit browser and source-data identities. They do not drift silently with a dependency update. + +```console +pliego-cssc compatibility --targets baseline-widely +``` + +## Bind budgets to owners {#budgets} + +Direct files can name budget subjects manually. Asset Plans use an ownership sidecar to aggregate package and composed-route costs without guessing responsibility. + +```console +pliego-cssc audit --asset-plan dist/pliego.assets.json \ + --ownership pliego.ownership.json --budget-policy budgets.json +``` + +## Audit static accessibility contracts {#accessibility} + +Contrast and interaction guards use explicit policy plus token evidence. Dynamic visual behavior still requires browser and assistive-technology testing. diff --git a/docs/site/syntax.md b/docs/site/syntax.md new file mode 100644 index 0000000..e235405 --- /dev/null +++ b/docs/site/syntax.md @@ -0,0 +1,31 @@ + + +# A finite language with visible edges. + +Utilities lower into semantic slots, conditions, and typed values before any CSS serialization occurs. + +## Utility plus typed value {#shape} + +Spacing, color, typography, layout, effects, and interactivity draw from one validated theme registry. Arbitrary escape hatches remain policy-visible. + +```text +flex items-center gap-4 rounded-lg bg-surface px-4 py-2 text-sm +``` + +## Conditions are structure {#conditions} + +Responsive, state, attribute, container, layer, and media variants become normalized condition paths. Their order is not incidental string decoration. + +```text +md:grid-cols-2 hover:bg-accent-strong aria-[expanded=true]:ring-2 +``` + +## Importance follows the physical effect {#important} + +A trailing exclamation mark marks the semantic contribution important. Synthesized effects such as the composed box-shadow preserve that authority in final CSS and lineage. + +```text +ring-2! shadow-md! +``` diff --git a/docs/site/themes.md b/docs/site/themes.md new file mode 100644 index 0000000..287c602 --- /dev/null +++ b/docs/site/themes.md @@ -0,0 +1,32 @@ + + +# Resolve tokens before styles. + +TOML themes and DTCG Resolver documents feed the same typed registry, identity, graph, and emitted variables. + +## Extend the seed {#toml} + +A project theme can override or add typed colors, spacing, radii, typography, and breakpoints without changing utility grammar. + +```text +schema = 1 +extends = "seed" + +[tokens.color] +brand = "oklch(62% 0.18 250)" +``` + +## Select DTCG contexts explicitly {#dtcg} + +Resolver inputs are never auto-discovered. Modifier selections participate in configuration identity even when two contexts resolve to equal values. + +```console +pliego-cssc compile --tokens product.resolver.json \ + --token-input appearance=dark --style "bg-brand" +``` + +## Publish the complete Token Graph {#graph} + +Controlled output binds selected CSS to canonical token provenance, aliases, permutations, and reachability-aware usage evidence. diff --git a/docs/site/tooling/cli.md b/docs/site/tooling/cli.md new file mode 100644 index 0000000..78df506 --- /dev/null +++ b/docs/site/tooling/cli.md @@ -0,0 +1,28 @@ + + +# One CLI, explicit modes. + +Compile, inspect, audit, transform, explain, bundle, watch, migrate, format, plan, and verify with strict argument contracts. + +## Discover the real command surface {#discover} + +Root and command help are generated from the maintained command synopsis. Unknown options, duplicate single-value options, and missing values fail. + +```console +pliego-cssc --help +pliego-cssc compile --help +``` + +## Select diagnostic transport globally {#diagnostics} + +Human and JSON diagnostics can be requested before or after the command without stealing option values that happen to resemble flags. + +```console +pliego-cssc --diagnostic-format json check --style "p-4 p-6" --seed +``` + +## Use --check in CI {#determinism} + +Commands that publish controlled or generated artifacts expose a read-only comparison path. CI should verify the checked-in or staged output, not regenerate it silently. diff --git a/docs/site/tooling/lsp.md b/docs/site/tooling/lsp.md new file mode 100644 index 0000000..0f48670 --- /dev/null +++ b/docs/site/tooling/lsp.md @@ -0,0 +1,19 @@ + + +# Compiler feedback without editor subprocesses. + +The LSP uses the same in-process `AnalysisHost` as CLI and watch without launching `pliego-cssc` or writing synthetic source files. + +## Diagnostics, completion, and hover {#features} + +Visible `pc!` and `pcx!` literals receive shared diagnostics, catalog-backed completion, and compiler-emitted hover CSS under the exact active theme. + +## Suppress stale document work {#cancellation} + +Document versions are debounced for 150 ms, and version checks around bounded in-process analysis prevent obsolete results from being published. + +## Bound analysis {#limits} + +Each document is capped at 256 semantic checks, the shared cache is bounded and theme-invalidated, and excessive input emits `PCL002`. diff --git a/docs/site/tooling/repair.md b/docs/site/tooling/repair.md new file mode 100644 index 0000000..7fddece --- /dev/null +++ b/docs/site/tooling/repair.md @@ -0,0 +1,29 @@ + + +# Plan first. Authorize exact bytes. + +Generate bounded repair proposals, bind authorization to source hashes, verify in staging, then publish receipts. + +## Join findings to a proposal {#plan} + +Repair plans name exact finding codes, files, byte ranges, replacements, expected hashes, and verification commands. + +```console +pliego-cssc plan --findings findings.json \ + --proposal proposal.json --source-root . +``` + +## Verify before mutation {#dry-run} + +Dry-run applies the authorized patch inside a staging copy and executes bounded Rust or browser checks without replacing the source tree. + +```console +pliego-cssc fix --plan plan.json --findings findings.json \ + --source-root . --dry-run +``` + +## Time and output are part of safety {#resource} + +Test, browser, toolchain, and version subprocesses use explicit deadlines and output limits while the repair lock is held. diff --git a/docs/site/tooling/watch.md b/docs/site/tooling/watch.md new file mode 100644 index 0000000..dca6366 --- /dev/null +++ b/docs/site/tooling/watch.md @@ -0,0 +1,24 @@ + + +# Keep the last complete truth. + +Poll coherent snapshots, republish only valid groups, and retain the last valid output across transient edits. + +## Watch inputs and output {#run} + +The watcher accepts the same source, theme, target, format, manifest, reachability, and controlled-output options as compile. + +```console +pliego-cssc watch --source src --theme \ + --output dist/app.css --manifest dist/app.manifest.json +``` + +## Confirm one coherent snapshot {#snapshot} + +Inputs are observed twice before compilation so an editor save sequence does not publish a mixture of old and new files. + +## Never replace validity with a partial edit {#last-valid} + +A parser error, invalid token document, or failed group publication leaves the previous complete artifact set in place. diff --git a/docs/site/typed-styles.md b/docs/site/typed-styles.md new file mode 100644 index 0000000..37f5172 --- /dev/null +++ b/docs/site/typed-styles.md @@ -0,0 +1,32 @@ + + +# Types at author time. CSS at run time. + +Stable 128-bit identities connect Rust source, emitted classes, manifests, and physical declarations. + +## One literal, one stable identity {#pc} + +pc! accepts one visible string literal. The macro parses semantics, rejects conflicting slots, and returns a sealed Style. + +```rust +const CARD: Style = pc!( + "grid gap-4 rounded-xl border border-line p-6 shadow-md" +); +``` + +## Visible state spaces {#pcx} + +pcx! compiles every visible branch combination and selects one identity at runtime. Independent clauses that can collide are rejected. + +```rust +let style = pcx!( + "block", + if active { "opacity-100" } else { "opacity-50" } +); +``` + +## Follow the physical effect {#lineage} + +Manifest schema 5 joins semantic declarations to final rules, conditions, byte ranges, and synthesized effects such as box-shadow. diff --git a/docs/site/variants.md b/docs/site/variants.md new file mode 100644 index 0000000..a00d322 --- /dev/null +++ b/docs/site/variants.md @@ -0,0 +1,31 @@ + + +# Conditions without string-order folklore. + +Every variant joins a typed condition path with deterministic normalization and specificity behavior. + +## Responsive paths {#responsive} + +Theme breakpoints are validated once and emitted as ordered media conditions. Assignments at different breakpoints do not conflict. + +```text +grid-cols-1 md:grid-cols-2 lg:grid-cols-3 +``` + +## State and attributes {#state} + +Pseudo-class and bounded attribute variants stay in semantic IR so policy and physical lineage can explain their selectors. + +```text +hover:bg-accent focus-visible:ring-2 data-[state=open]:opacity-100 +``` + +## Cascade layers {#layers} + +Fixed layer variants model author-controlled precedence without allowing arbitrary source-order dependence to leak into identity. + +```text +layer-components:bg-surface layer-utilities:p-4 +``` diff --git a/docs/status/f0-verification.md b/docs/status/f0-verification.md index 911e0fc..4cf60b0 100644 --- a/docs/status/f0-verification.md +++ b/docs/status/f0-verification.md @@ -8,7 +8,7 @@ Date: 2026-07-12 - Cargo 1.96.0. - Node 24.16.0. - pnpm 11.7.0. -- Tailwind CSS and CLI 4.3.2. +- Tailwind CSS and CLI 4.3.2 (historical F0 environment; not the current oracle). - Windows 10.0.26200, x64. - Intel Core Ultra 9 285H, 16 logical CPUs. diff --git a/docs/status/f0.md b/docs/status/f0.md index 25ac6d3..ca56db9 100644 --- a/docs/status/f0.md +++ b/docs/status/f0.md @@ -13,7 +13,7 @@ Updated: 2026-07-12 - [x] Built-in condition normalization separated from ordered selector transforms. - [x] Initial 40-family utility catalog selected. - [x] Five Tailwind v4 fixtures created. -- [x] Tailwind v4.3.2 and CLI pinned with pnpm. +- [x] Tailwind v4.3.2 and CLI pinned with pnpm for the historical F0 snapshot. - [x] Fresh-process benchmark harness created. - [x] Full, no-preflight, complete, and Gate-A core profiles defined. - [x] Workspace and five initial Rust crates scaffolded. diff --git a/docs/status/f6.md b/docs/status/f6.md index 5d91ceb..7901a1e 100644 --- a/docs/status/f6.md +++ b/docs/status/f6.md @@ -136,38 +136,35 @@ evidence and RC activation remain open** cross-repository fixture. - [x] Add opt-in, collision-safe application of Rust source-format fixes while preserving the existing read-only diagnostic mode. -- [x] Add a standard stdio LSP transport over catalog schema 3, explain schema 2, in-memory scanner - diagnostics, UTF-16 conversion, and collision-safe whole-literal formatting edits. +- [x] Add a standard stdio LSP transport over the shared in-process compiler catalog/engine, + in-memory scanner diagnostics, UTF-16 conversion, and collision-safe whole-literal formatting edits. - [x] Add an unreleased VS Code client candidate for file-backed Rust documents with explicit - external binary/theme configuration and a typecheck/test/bundle/VSIX package gate. + external server/theme configuration and a typecheck/test/bundle/VSIX package gate. - [x] Add opt-in Project Index schema 1/2 navigation from an exact Rust utility site to every bundle-qualified physical CSS declaration. The LSP verifies source, Asset Plan, manifest, and CSS integrity plus schema-5 ranges and fails closed on stale or mixed artifacts. -- [x] Reuse `pliego-cssc check` diagnostic schema 1 for bounded, cached, theme-aware validation of - individual visible literals, preserving compiler codes/messages and exact UTF-16 editor ranges. -- [x] Run a real VS Code 1.105.1 extension host against the development extension, external Rust - 1.85 server/compiler binaries, and a local workspace. The gate follows the Project Index to the - exact final CSS range, mutates the open Rust buffer, and observes exact compiler-backed `PCS001` +- [x] Reuse `AnalysisHost` for bounded, cached, theme-aware validation of individual visible + literals, preserving compiler codes/messages and exact UTF-16 editor ranges without CLI children. +- [x] Run a real VS Code 1.105.1 extension host against the development extension, an external Rust + 1.85 server, and a local workspace. The gate follows the Project Index to the exact final CSS + range, mutates the open Rust buffer, and observes exact shared-engine `PCS001` plus cross-clause `PCX003`. - [x] Debounce theme-aware semantic checks for 150 ms, execute them outside the protocol thread, and discard results whose full-document version is stale. The Windows and Debian process gate sends versions 2–10 as a burst, proves request responsiveness, and accepts only version 10. -- [x] Cancel and reap the direct compiler child when a newer version for the same URI makes its - snapshot stale. The worker drains stdout and stderr concurrently, does not cache or publish a - cancelled result, and resumes with the latest debounced job. Windows and Debian start a real - 30-second blocking proxy, prove its PID exits, and receive the replacement `PCS001` within the - five-second gate timeout. -- [x] Reuse `pliego-cssc check --source` for semantic `PCX003` equality across independently - selectable clauses. One bounded literal-only synthetic document per buffer preserves compiler - ownership of overlap semantics and maps the conflict back to the exact original branch token; - both the process gate and VS Code 1.105.1 extension host exercise an exact duplicate. -- [x] Add a bounded Neovim built-in-LSP client with explicit external binaries and a 3,466-byte +- [x] Suppress stale in-process semantic work when a newer URI/version supersedes its snapshot. + Version checks before and after bounded analysis prevent obsolete publication; the integration + gate accepts only the latest `PCS001` and reports zero compiler processes. +- [x] Share `PcxRequest` semantic `PCX003` analysis across procedural macros, source scanning, and + LSP. The editor maps the shared conflict directly to the original branch token without synthetic + Rust source or a compiler child. +- [x] Add a bounded Neovim built-in-LSP client with an explicit external server and a closed closed payload. Official Neovim 0.12.4 hosts pass Project Index definition, `PCS001`, and `PCX003` on Windows x64 and Debian x86-64 with pinned archive hashes. - [x] Freeze diagnostic corpus schema 2 with twenty cases covering PCS001–PCS012, PSC001–PSC006, PCR001, and FMT001. Windows and Debian compare exact code, message, range, severity, suggestion, - and typed replacement against CLI schema 1; the same session covers PCX003 and fault-injected - PCL001/PCL002. Parser spans, including zero-width spans, now project exactly; Rust parse failures + and typed replacement against CLI schema 1; the same session covers PCX003 and bounded PCL002. + Parser spans, including zero-width spans, now project exactly; Rust parse failures are typed in both transports and scanner codes survive context prepended by `syn`. - [x] Publish local unreleased VS Code/Neovim onboarding and LSP troubleshooting, add exclusive `pliego-css-lsp --version`, and verify all 127 Markdown files plus 442 local links through diff --git a/docs/status/g5.md b/docs/status/g5.md new file mode 100644 index 0000000..e4c4dac --- /dev/null +++ b/docs/status/g5.md @@ -0,0 +1,33 @@ +# G5 repository distribution status + +Status: **architecture and local contracts implemented; exact-source hosted distribution evidence is +required before G5 can close for a candidate** + +## Scope + +G5 owns the native CLI/LSP and optional Node launcher distribution boundary. It does not change the +nineteen-crate crates.io compatibility unit and does not authorize a final release. + +## Implemented contracts + +- [x] Repository-only machine policy with npmjs publication forbidden. +- [x] Private universal `@pliegocss/cli` package with no dependencies or lifecycle scripts. +- [x] Shell-free host selection for Windows x64, Linux x64 GNU, and macOS arm64. +- [x] Native payload and aggregate manifest validators with PE/ELF/Mach-O guards. +- [x] SHA-256 asset inventory and CycloneDX 1.5 SBOM validation. +- [x] Local pnpm pack/bundle contract using three format-correct fixtures. +- [x] Hosted three-platform native build and three-platform pnpm install workflow. +- [x] Conditional GitHub/Sigstore binary, SBOM, and release-asset attestations. +- [x] Tag path creates only a draft release and fails closed when release immutability is disabled. + +## Evidence still required + +- [ ] A clean hosted workflow run on the final G5 source across all native and install lanes. +- [ ] One reviewed manual exact-source run with `attest=true` and successful `gh attestation verify`. +- [ ] Repository immutable releases enabled before any tag path is used. +- [ ] A newly named candidate whose tag, commit, tree, artifacts, SBOMs, checksums, and attestations + all agree. +- [ ] Owner authorization before creating that candidate tag or publishing its draft Release. + +RC.2 remains blocked because it predates these artifacts. Branch or later-candidate evidence cannot +be copied into its readiness record. diff --git a/docs/status/portability.md b/docs/status/portability.md index 0380219..e9deba6 100644 --- a/docs/status/portability.md +++ b/docs/status/portability.md @@ -1,220 +1,104 @@ # Portability contract -Updated: 2026-07-15 +Updated: 2026-07-22 -Status: **executable contract implemented; hosted matrix evidence and Cloudflare remain open** +Status: **executable contract and hosted CI matrix implemented; release-candidate binding remains +open** -## Configured matrix +## Authority -The Rust CI matrix targets: +`.github/workflows/ci.yml` runs the same portability contract on Ubuntu, Windows, and macOS with +Rust 1.85.0 and 1.96.0. Node.js 22.13.0 executes the byte-level vector on every Rust/OS lane. A run +is evidence only for its exact `head_sha`; a green branch run does not certify an older tag and does +not promote `0.1.0-rc.2`. -- Ubuntu, Windows, and macOS; -- Rust 1.85.0 (MSRV) and 1.96.0 (current pinned toolchain); -- workspace tests and Clippy with warnings denied; -- Node.js 22.13.0 executing the same portability contract on every host. This matches the minimum - engine required by the pinned pnpm 11.7.0 toolchain. +The separate browser-output workflow owns the G4 Windows/Linux/macOS × Chromium/Firefox/WebKit +matrix. Portability proves compiler and artifact identity; browser output proves rendered behavior. +Neither substitutes for a named release-candidate replay. -The WASM job also checks `pliego-css` with Rust 1.85.0 for -`wasm32-unknown-unknown` before running the existing Rust 1.96 zero-overhead micro-fixture. This is -not a claim of WASI, `no_std`, every WASM target, or a deployed Cloudflare Workers build. - -The repository currently has no configured Git remote, so this document distinguishes the checked-in -matrix from hosted green-run evidence. The current canonical-token-graph vector has passed locally on -Windows x64 with the checkout's default Rust toolchain and Node.js 24.16.0, and on Debian WSL2 Linux -x64 with the same graph-bearing hashes under Node.js 20.19.2. The Linux Node version is extra local -evidence and does not lower the supported Node.js 22.13.0 floor. The earlier Rust 1.85, -Node.js 22.13.0, and MSRV WASM observations predate `pliego.tokens.json`; they remain superseded for -this vector until rerun. +The WASM job additionally checks the public crate with Rust 1.85.0 for +`wasm32-unknown-unknown`. This is not a WASI, `no_std`, every-WASM-target, or deployed Cloudflare +Workers claim. ## Frozen executable vector -`node scripts/check-portability.mjs` creates an isolated bundle-plan project and verifies: - -- identical CSS and schema-3 manifest bytes when invoked with the same absolute plan/output arguments - from two unrelated process directories; -- plan, source, output, and process-directory paths nested below a directory containing a space; -- a UTF-8 path with an accented character and astral content before the scanned macro; -- CRLF Rust source input whose manifest range selects the exact macro bytes, with `/` manifest paths; -- manifest byte count and SHA-256 binding to the adjacent CSS; -- a successful `--check` leaves existing artifact bytes, size, modification time, inode identity, and - the output tree unchanged, while a missing-output check fails with an empty final directory and no - persistent locks; -- a drift failure is read-only; -- source and theme paths cannot escape through an intermediate Unix symlink or Windows junction; -- a linked output directory fails before publication; -- explicit `.` path segments fail closed; -- ASCII case-insensitive config/output aliases fail before any mutation on every host, so Linux - validation models the collision boundary of a later Windows replay. -- direct-CSS audit control artifacts have frozen byte hashes, and `--check` verifies the complete - findings/manifest/receipt group without changing it. -- a manifest-5 `bundle --control` vector binds exact reachability, CSS/source-map/style-manifest, - Asset Plan, Project Index, canonical TokenGraph, findings, control manifest, and receipt bytes; its - `--check` is also read-only and rejects token-graph drift without repairing it. -- a separate `audit --asset-plan` invocation reopens the on-disk plan and adjacent CSS/manifest pair, - proves canonical regeneration, freezes its own control group, and verifies read-only `--check`. -- exact-snapshot compile and watch each freeze CSS, Source Map v3, style manifest, canonical - TokenGraph, findings, control manifest, and receipt: seven artifacts per vector. Compile `--check` - proves the finite group is read-only and rejects token-graph drift without repairing it. Each vector - verifies compact canonical fields, non-empty mappings, logical Unicode source identity, and exact - manifest byte/hash cross-references while proving CSS has no injected discovery comment. -- every generated TokenGraph parses as compact canonical JSON, declares - `pliegocss-token-graph/1`, matches `tokens.graphHash`, and is reciprocally related to the generated - CSS and style manifest in the control manifest. - -The separate `pnpm check:manifest` fixture uses only plan-relative logical paths and freezes -schema-4 graph bytes across the same host-independent source/ThemeId boundary. The established -portability vector remains default schema 3, so opt-in graph metadata cannot silently change its -bundle bytes. - -`pnpm check:trace` adds a separate host-independent schema-5 golden with exact UTF-8 CSS ranges and -proves that schema 5 projects to the frozen schema-4 graph without changing the CSS artifact. - -The vector freezes these bytes for both supported Rust toolchains: - -| Artifact | Contract | +`node scripts/check-portability.mjs` builds an isolated project and fails closed unless all of these +properties hold: + +- identical CSS and manifest bytes from unrelated process directories; +- a path containing a space, UTF-8 `src/café.rs`, an astral character, and CRLF source bytes; +- exact logical provenance and macro byte ranges with forward-slash paths; +- CSS byte count and SHA-256 bound by the adjacent style manifest; +- read-only `--check`, including missing-output and drift failures; +- rejection of symlink/junction escape, linked output/theme paths, dot segments, and + ASCII-case aliases before publication; +- exact direct-audit, bundle-control, Asset Plan, compile, watch, and DTCG control groups; +- compact canonical `pliegocss-token-graph/1` bytes, reciprocal artifact relationships, and receipt + checks; +- complete Resolver 2025.10 projection: 12 sources, four canonical selections, 53 tokens per theme, + one alias, one deprecation, and `{ "color": 3 }` DTCG inventory; +- selection identity in `configHash` even when the resulting ThemeId and artifact bytes converge. + +The current shared-engine vector freezes: + +| Artifact | Frozen value | |---|---:| | CSS bytes | 424 | -| CSS SHA-256 | `d208d2960bd28fb29354b1a89c74c106686c8a8a2d71a9cc7cc9971eb2e97070` | -| Manifest SHA-256 | `3a89e04856d5ff8244c29e91325271d6a57e33eec99242b121308c477facd169` | +| CSS SHA-256 | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` | +| Style manifest SHA-256 | `453f708c9c4edbd388eda1ee9c883212cd5d31421077d123cb1d32bc0b28d4bb` | +| Seed TokenGraph SHA-256 | `3298873e66ade310459593530c86419c05a973d2404b71882c2d0ae430030a28` | | Provenance path | `src/café.rs` | -The direct-CSS audit vector additionally freezes: +The generation/control groups freeze these identities in addition to their complete per-file maps +inside `scripts/check-portability.mjs`: -| Control artifact | SHA-256 | -|---|---| -| `pliego.css.findings.json` | `75015787cd20bd52e4c1d504943b675785f55d7376a583a103cad4ab5c7de464` | -| `pliego.css.manifest.json` | `bac4099153b23cfd990fc2e70506153d08b725ca27661ad102934f45df721ac1` | -| `pliego.css.receipt.json` | `cb680d32a41321b089cf2fd98b7f60501fa8ba306f408d5f448a11551598b9b1` | +| Group | CSS or primary artifact | Control manifest | Receipt | +|---|---|---|---| +| Direct CSS audit | `79e4817107cbb7a20b5c0be1a8a881b11ebfc3f9e94f83a93f9d318f74059cf9` findings | `711c183edb566062d3feec8e7a7e42117a8b6b6b0d05d237afc0469abd09d78b` | `1162d5743e2ccd7d03614605a700cd55099bde9380c19df5d95d9cfb65875479` | +| Bundle control | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` CSS | `cf7e29c1a78cc63120b53a0b88b7c9395edd51f9322a0c6c2b565a8a03458475` | `da835752a54b00ec3d7922ec1394958c4c1465c728c86f21f6474540242e1d7c` | +| Reopened Asset Plan | `e036dcb3587507db07e39a55cca5404dd074b4ede3abb70e487005e18ab478b7` findings | `a4e31d7f69c2ed88447eaf984c0bfdf6e85180a2ea80990234a51cb5141cc9ba` | `43d2297a39dd74dacd71a49ffee43febee1ba9e83d80bf0a55e1435eb9944fb8` | +| Compile control | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` CSS | `92e17db715bef4de971f75da73c790b68c2cc1b017edae91b4df283d5d9b6277` | `4a4321f4142bfe0a06ebb419223fce693b82966ed2e72d4230cba71d52beaf3c` | +| Watch control | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` CSS | `8fc31e3d221d70fe99931fcb8c1a2e414ed4a6e78145a667a179d7c3ada03543` | `3f43fa1238c25ed4f17942c4b6ef42d2c1610fe6ffdc80e4153397066c2335c1` | +| Direct DTCG | `9a349f5059ffb3ace6c985dfe1fadcd9cb9ac45668b5cd0c00e26625e5a6c6c3` CSS | `8758c9d6b69e42f5adc48ba7d84f4fb026618af8737fea690185bb53dd7f1994` | `aafe05a73d83a00aaa3be700bc554624389c60fb351e2f7a1c8d2cd6f0cae5aa` | -The manifest-5 bundle-build vector freezes the complete nine-artifact group: +The direct DTCG vector freezes `examples/product.resolver.json` at +`5965f868707ad92b0164788b5a7b8b8a2234ce03d7e6c71e1df66d612801b876`, the complete graph at +`8659f9c443cb5c77c399fd741a27d476ec18887742c5459002fa6b82c22e864a`, and these selection hashes: -| Bundle artifact | SHA-256 | -|---|---| -| `app.css` | `d208d2960bd28fb29354b1a89c74c106686c8a8a2d71a9cc7cc9971eb2e97070` | -| `app.css.map` | `39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf` | -| `app.manifest.json` | `0b04b7cf34339e598aeb256c5667a808461dfd02e90ce18aaec0a60de501eb9a` | -| `pliego.assets.json` | `b004fd1b8c28a13b1af0f80c105fc71a7615bab45ea3a1619f7f65084d1b343e` | -| `pliego.css.findings.json` | `e03d275890f0db3ccceaddfb6dc5b43acd0f0f731b99c77bd0b9bd9b6bf61d40` | -| `pliego.css.manifest.json` | `19bbb01d407c958d0938ae536c4c08889d1084fd86f6ee246bae0d0326a98db3` | -| `pliego.css.receipt.json` | `2bac654c6060a50f4a1fa21ea22baa1ef5d87c7f741aa37c41a5068dba1bf52d` | -| `pliego.index.json` | `6d60bcce6f91e4dc71ca98d921e9ce8bb267cb4d027d51d664b4e87477313dec` | -| `pliego.tokens.json` | `bb525a903714688d1ada713bff7a0c831631ee1c08b10434d1d03c79d742d204` | - -The independently reopened Asset Plan audit vector freezes: - -| Asset Plan audit artifact | SHA-256 | +| Selection | `configHash` | |---|---| -| `pliego.css.findings.json` | `67e1d24a75adc042312286ade5572379ec706df1ad177031b9160862d27c5e59` | -| `pliego.css.manifest.json` | `004d39320c6aab32a6293896db98bbef21a040ec457afc2114e82fc67e7ab795` | -| `pliego.css.receipt.json` | `d392124440e0295760fb5e8629fb5e4d07271c1f5fcffc03b628bdfbdbab65ce` | +| `appearance=dark,channel=light` | `sha256:e6ce8e2578b2bbb68c0b533495dab2a781e5c7a32665d4c4a4845146f014ba3e` | +| `appearance=dark,channel=dark` | `sha256:5bab41c2b1a8d06d05fbaae1517d425a35b0bb8143fcdaeaee3c5faf48d9469e` | -The exact-snapshot direct compile vector freezes: +## StyleId format-2 refresh audit -| Compile artifact | SHA-256 | -|---|---| -| `app.css` | `d208d2960bd28fb29354b1a89c74c106686c8a8a2d71a9cc7cc9971eb2e97070` | -| `app.css.map` | `39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf` | -| `app.manifest.json` | `3a89e04856d5ff8244c29e91325271d6a57e33eec99242b121308c477facd169` | -| `pliego.css.findings.json` | `b59823b27e3f045625bcc9a8ecc3842de039ae1d70d2c98348bd0444550abe04` | -| `pliego.css.manifest.json` | `51bd81698f58ea3c4ab66c6f5c111135523b62e0177f970a6ae40229ac96d247` | -| `pliego.css.receipt.json` | `f94939da3e2c04c8d373b2fe881e9234b5abcee8bbb81b6b0d7e1a9c3fdb4fce` | -| `pliego.tokens.json` | `bb525a903714688d1ada713bff7a0c831631ee1c08b10434d1d03c79d742d204` | +The shared pure compilation engine intentionally moved the portable output from the RC.2 identity +stream to StyleId format 2. Before updating the current goldens, a detached `v0.1.0-rc.2` worktree at +`064dcbce96a3a5cc97a940d07566c008bb5e2d3e` was built with Rust 1.85.0 and replayed with Node.js +22.13.0. It reproduced the legacy CSS hash +`acdb0bfee613ddb3fdae0feb9a8e5118dc5f74d385ed53eb610ff4eefd05d454`, manifest hash +`8c1dc690c2cbb8102b6935a970e60938bbda7e50a0c0bf766c5c800305cce598`, and every legacy control +hash byte for byte. -The exact-snapshot watch vector freezes: +The current checkout then reproduced the new complete vector with Node.js 22.13.0 and 24.16.0. +CSS remains 424 bytes; source bytes, Resolver hash, graph cardinalities, canonical selections, +read-only checks, and rejection cases are unchanged. Only identities and artifacts derived from the +new identity stream were refreshed. The temporary audit worktree and Docker volume were removed +after verification. -| Watch artifact | SHA-256 | -|---|---| -| `app.css` | `d208d2960bd28fb29354b1a89c74c106686c8a8a2d71a9cc7cc9971eb2e97070` | -| `app.css.map` | `39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf` | -| `app.manifest.json` | `3a89e04856d5ff8244c29e91325271d6a57e33eec99242b121308c477facd169` | -| `pliego.css.findings.json` | `3df2586a1041d39a0e3e66db4e6674df39479157b3ac413c0fea8b7f85ad23bd` | -| `pliego.css.manifest.json` | `b0713b0a3957f267e960ec026088441f4a4e3d476e52becd30c1cfe08af0aa64` | -| `pliego.css.receipt.json` | `fe5633bfa90a7edb953e390e5185597fd909d5f9b696059f36dd1e2c0ed6d2e9` | -| `pliego.tokens.json` | `bb525a903714688d1ada713bff7a0c831631ee1c08b10434d1d03c79d742d204` | - -The direct DTCG Resolver vector selects `appearance=dark,channel=light`, freezes the exact -`examples/product.resolver.json` input at -`5965f868707ad92b0164788b5a7b8b8a2234ce03d7e6c71e1df66d612801b876`, and freezes: - -| Direct DTCG artifact | SHA-256 | -|---|---| -| `app.css` | `cec35891bc4d167bb359b57cf5fcda10a06e2b0665398424ff63b8e01c8b4850` | -| `app.css.map` | `ce30a183a990c8dca861721b4c5ea953884fd4fb4898220023192e20abc62c3c` | -| `app.manifest.json` | `88597038d0aab327fc11d11a4371b03e1aa53b44199cc137eb84729f3eddd00a` | -| `pliego.css.findings.json` | `2135fdb346d08d7e25516006a9d7829780d155b8e1e2374f19ddd7a08a7916c9` | -| `pliego.css.manifest.json` | `9fb936c7c415af5c78a3edd298e31e6a4d750a84374e830961b2472117ac3e97` | -| `pliego.css.receipt.json` | `b24d23b286df8c35711684c585962a2b25d84bf1c6de84b7c618c08101738183` | -| `pliego.tokens.json` | `7a0862dcd2cd8152cface3ea82fbccf1877e75c6f505efa8a3032f8652f354f0` | - -The vector also freezes `configHash` as -`sha256:e06966ae5f001ba6b1b29bfa4d52a3c3d26f9c4ba588d8ff558bd820c23767a9`. -Changing only `channel=dark` retains the same `ThemeId`, CSS, style manifest, and complete graph but -changes `configHash` to -`sha256:d0ab9d7cdc46d564adc14142be10d15b14dee36066966a29f4857e22a0f3616c`. -This proves that canonical selections remain identity-bound independently of the compiled registry. - -The generated bundle/compile/watch manifest hashes include each exact Source Map v3 reference, the -canonical `pliegocss-token-graph/1` artifact and identity, reciprocal graph relationships, and -distinct referenced-token coverage. Direct CSS and reopened Asset Plan audit remain unchanged because -they do not own generation and their token state is explicitly unavailable. - -On 2026-07-14 the complete updated script passed all six exact control groups on local Windows x64 -using Node.js 24.16.0 and again on Debian WSL2 Linux x64 using Node.js 20.19.2 with its runtime on the -native Linux filesystem. The Linux replay used the graph-bearing bundle/compile/watch and direct -DTCG vectors shown above, including canonical graph parsing, exact Resolver-ledger identity, -case/order convergence, reciprocal relationships, exact receipt evidence, drift rejection, and -read-only checks. All frozen direct DTCG hashes matched Windows byte for byte. No -hosted-runner, macOS, Rust 1.85 graph-bearing, supported-floor Node.js 22.13, or Cloudflare -portability claim is made. - -Bundle-plan schema 2 also passed its three DTCG E2E cases on local Windows x64 and on Debian WSL2 -Linux x64 with a native Linux target directory. Those cases validate schema-1 compatibility, -default/dark contexts, exact `token-resolver` evidence, the complete graph, same-ThemeId selection -identity, read-only `--check`, and preservation of the last valid group. This is cross-OS execution -evidence, not a new frozen byte vector: the temporary plan path and exact plan bytes intentionally -participate in bundle `configHash`. - -On 2026-07-15 the Cargo build-macro DTCG bridge passed the complete Debian WSL2 Linux x64 workspace -with a native Linux target directory, Clippy with warnings denied, the locked Rust 1.85 workspace -check, and the Rust 1.85 downstream public-API identity smoke. That smoke froze -`StyleId=1b052ee4ca1192db2e1ef91594166f70`, -`ThemeId=aeba2dade1fb8ef54e6db50494167fbd`, 722 CSS bytes, and CSS SHA-256 -`25a0aa84e0ba5256c21ecd5740d9e6094632aeb2fc5600992f9d4f16013efd2d`; it also proved TOML registry -convergence and direct DTCG macro/CLI identity. Linux executed an actual file-symlink rejection. - -The post-change Windows x64 dirty package gate compiled every archive and ran separate extracted -Rust 1.85 DTCG and legacy-TOML build-script consumers with byte-identical selected-registry output. -The Windows file-symlink test is conditional: this checkout could not create the link because the OS -returned error 1314, so this milestone does not claim an observed Windows reparse-point rejection. -The code still fails closed on reparse metadata and opens with `FILE_FLAG_OPEN_REPARSE_POINT`, but -that is an implemented contract rather than executed evidence here. Native macOS, native ARM64, -and hosted runners remain open. Commit `9714b09` passed the complete clean Debian WSL2 package gate: -all eleven archives compiled, and the extracted Rust 1.85 DTCG and legacy-TOML consumers produced -byte-identical output. - -Changing one of these values requires an intentional compatibility review, not a silent fixture -update. - -## Line-ending contract - -`.gitattributes` fixes LF for source, configuration, documentation, workflow, fixture, CSS, HTML, and -diagnostic snapshot files. Binary media and WASM remain binary. The explicit CRLF source inside the -portability vector proves that scanner offsets still describe the exact supplied bytes; it does not -make checked-in line endings host-dependent. +Changing any frozen value still requires an intentional compatibility review. Update mode prints +witness values but does not bypass semantic assertions. ## Boundaries still open +- A new named release candidate must bind its tag, source commit, tree, portability run, browser + matrix, registry replay, production replay, signed distribution, and final authorization. RC.2 + cannot inherit this branch's evidence. - Normal compile/watch without `--control-dir` preserves author-supplied provenance and is not - promised byte-identical across hosts. Controlled compile/watch and bundle-plan provenance use - portable logical paths and are the cross-OS contracts. -- The hosted matrix must run green after a remote repository exists. -- The canonical-token-graph vector still needs macOS, Rust 1.85.0, and supported-floor Node.js - 22.13.0 evidence; local Linux x64 is now covered but is not hosted evidence. -- Hosted runner labels and action major tags are intentionally not release-pinned yet; supply-chain - SHA pinning and recording the runner image belong to the release gate. -- PliegoRS integration still depends on a dirty sibling checkout and is not a clean-clone CI job. -- Filesystem locks are advisory and are not certified for NFS/SMB or non-PliegoCSS writers. -- Grouped publication is rollback-capable for handled failures, not gap-free, crash-atomic, or durable - against power loss. A stale compiler process can still publish after a newer compilation. -- Browser behavior still lacks a Chromium/Firefox/WebKit matrix. -- Cloudflare Workers deployment and an application-level WASM/SSR gate remain separate work. + promised byte-identical across hosts. +- Filesystem locks are not certified for NFS/SMB or non-PliegoCSS writers; grouped publication is + rollback-capable for handled failures, not durable crash atomicity. +- The WASM check is a library/target gate. Cloudflare Workers execution and application-level SSR + remain separate release evidence. +- Native ARM64 binaries and signed distribution artifacts remain publication work even though the + hosted macOS runner contributes compiler and browser evidence. diff --git a/docs/troubleshooting/lsp.md b/docs/troubleshooting/lsp.md index 04dcd45..d116a5e 100644 --- a/docs/troubleshooting/lsp.md +++ b/docs/troubleshooting/lsp.md @@ -5,15 +5,16 @@ The current clients deliberately fail closed; they do not search the network or ## The server does not start -1. Run both configured executables directly with `--version`. -2. Confirm they come from the same checkout revision and lockfile. -3. Use absolute paths. Neovim requires readable executable files; VS Code resolves its process from +1. Run the configured `pliego-css-lsp` executable directly with `--version`. +2. Confirm it comes from the same compatibility unit as the project's PliegoCSS packages. +3. Use an absolute path. Neovim requires a readable executable; VS Code resolves its process from the first local workspace folder. 4. Confirm the buffer is a saved Rust file. Untitled/virtual buffers are unsupported. 5. Restart the client after changing executable paths. -An unavailable compiler produces `PCL001`; it is not converted into a guessed style diagnostic. -Capture the complete message before replacing paths or theme settings. +The legacy compiler-path setting is ignored. A missing or invalid theme can produce `PCL001`; it is +not converted into a guessed style diagnostic. Capture the complete message before changing theme +settings. ## Diagnostics disagree with the CLI @@ -25,8 +26,8 @@ pliego-cssc --diagnostic-format json check --source src --seed Replace `--seed` with the exact configured TOML or DTCG selection when appropriate. Current corpus schema 2 requires CLI/LSP equality for code, message, range, severity, category, suggestion, and -typed replacement across PCS, PSC, PCR001, FMT001, and PCX003. `PCL001` and `PCL002` are operational -fallbacks tested through fault injection. +typed replacement across PCS, PSC, PCR001, FMT001, and PCX003. `PCL001` and `PCL002` remain +operational configuration and bounded-analysis findings rather than authoring corpus cases. If only an escaped cooked Rust literal has a wider range, that is intentional: decoded offsets cannot always be mapped safely through escapes, so the server selects the complete literal token. @@ -55,16 +56,16 @@ mixed snapshot and must continue to fail. ## Diagnostics arrive twice or appear stale -Local parser/scanner/format diagnostics publish immediately; compiler-backed diagnostics arrive -after the 150 ms debounce. A newer full-document version cancels the stale direct compiler child and -its result is neither cached nor published. If an editor plugin sends incremental changes instead of +Local parser/scanner/format diagnostics publish immediately; theme-aware semantic diagnostics arrive +after the 150 ms debounce. A newer full-document version supersedes pending work, and version checks +around in-process analysis prevent stale publication. If an editor plugin sends incremental changes instead of the negotiated full-document synchronization, the server rejects that request. ## A large file reports PCL002 One document is capped at 256 semantic utility-literal checks. `PCL002` with -`semantic diagnostic literal limit exceeded` means the server stopped before launching an unbounded -number of compiler processes. Split generated or unusually large Rust source units; do not suppress +`semantic diagnostic literal limit exceeded` means the server stopped before performing unbounded +semantic work. Split generated or unusually large Rust source units; do not suppress the finding and assume the remaining literals were validated. ## Collect reproducible evidence @@ -77,6 +78,6 @@ pnpm integration:vscode-host pnpm integration:neovim ``` -Report the editor version, OS, exact PliegoCSS commit, both binary paths/versions, theme mode, and +Report the editor version, OS, exact PliegoCSS commit, server path/version, theme mode, and whether Project Index navigation was enabled. Do not attach proprietary source or theme documents without redaction and authorization. diff --git a/editors/neovim/README.md b/editors/neovim/README.md index feafb59..c3b4c22 100644 --- a/editors/neovim/README.md +++ b/editors/neovim/README.md @@ -1,23 +1,22 @@ # PliegoCSS for Neovim -This unreleased client uses Neovim's built-in LSP client and external, same-version -`pliego-css-lsp` and `pliego-cssc` binaries. It does not download or embed PliegoCSS executables. +This unreleased client uses Neovim's built-in LSP client and one external `pliego-css-lsp`. It does +not download or embed PliegoCSS executables, and editor semantics do not require `pliego-cssc`. -Add this directory to `runtimepath`, then configure explicit paths: +Add this directory to `runtimepath`, then configure: ```lua require("pliegocss").setup({ server = "/exact/path/to/pliego-css-lsp", - compiler = "/exact/path/to/pliego-cssc", root_dir = vim.fn.getcwd(), theme = { mode = "seed" }, -- or discover; config requires path project_index = "target/site/assets/pliego.index.json", -- optional }) ``` -Only file-backed Rust buffers are started by the `FileType` autocmd. Invalid paths, theme modes, -and missing optional artifacts fail before a client starts. The project root determines compiler -discovery and relative configuration behavior. +Only file-backed Rust buffers are started by the `FileType` autocmd. Invalid server/config paths, +theme modes, and missing optional artifacts fail before a client starts. A legacy `compiler` key is +accepted as an unused table member for 0.1 configuration compatibility. Run the pinned real-host gate from the repository root: @@ -25,7 +24,5 @@ Run the pinned real-host gate from the repository root: pnpm integration:neovim ``` -The first run downloads the official Neovim 0.12.4 archive into an ignored cache and verifies its -frozen SHA-256 before extraction. The headless host opens a real Rust buffer, follows Project Index -definition, and observes compiler-backed `PCS001` and cross-clause `PCX003` diagnostics. The test -runtime download is gate infrastructure; the client module never downloads an editor or server. +The headless host opens a real Rust buffer, follows Project Index definition, and observes +shared-engine `PCS001` and cross-clause `PCX003` diagnostics. diff --git a/editors/neovim/lua/pliegocss/init.lua b/editors/neovim/lua/pliegocss/init.lua index aebc9be..f6475cb 100644 --- a/editors/neovim/lua/pliegocss/init.lua +++ b/editors/neovim/lua/pliegocss/init.lua @@ -15,8 +15,7 @@ end local function command(options) local server = absolute_executable(options.server, "pliego-css-lsp") - local compiler = absolute_executable(options.compiler, "pliego-cssc") - local args = { server, "--pliego-cssc", compiler } + local args = { server } local theme = options.theme or { mode = "discover" } if theme.mode == "seed" then table.insert(args, "--seed") diff --git a/editors/neovim/tests/host/init.lua b/editors/neovim/tests/host/init.lua index 5ac401d..36e93d5 100644 --- a/editors/neovim/tests/host/init.lua +++ b/editors/neovim/tests/host/init.lua @@ -18,7 +18,6 @@ vim.opt.runtimepath:prepend(editor) require("pliegocss").setup({ server = required("PLIEGOCSS_NEOVIM_LSP"), - compiler = required("PLIEGOCSS_NEOVIM_COMPILER"), root_dir = workspace, theme = { mode = "seed" }, project_index = workspace .. "/out/pliego.index.json", diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 08bbc8b..0827489 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,21 +1,18 @@ # PliegoCSS for VS Code -This unreleased extension connects file-backed Rust documents to an externally installed -`pliego-css-lsp` process. It provides the completion, hover, diagnostics, formatting, and optional -source-to-CSS navigation capabilities advertised by that server. The extension never downloads or -updates native executables. +This unreleased extension launches an external `pliego-css-lsp` for file-backed Rust documents. It +does not download or embed native executables, and editor semantics do not require `pliego-cssc`. -Configure `pliegocss.server.path` and `pliegocss.compiler.path` to exact same-version binaries. Set -`pliegocss.theme.mode` to `discover`, `seed`, or `config`; config mode also requires -`pliegocss.theme.config`. Use **PliegoCSS: Restart Language Server** after changing external files. -Set `pliegocss.projectIndex.path` to a generated `pliego.index.json` to enable verified Go to -Definition from a Rust utility literal to its final physical CSS declarations. An empty value keeps -navigation disabled. +Configure `pliegocss.server.path`, select `discover`, `seed`, or `config` theme mode, and optionally +set `pliegocss.projectIndex.path` for verified source-to-CSS navigation. The historical +`pliegocss.compiler.path` setting is deprecated and ignored. -Only the first local workspace folder is currently used as the native process working directory. -Virtual and untrusted workspaces are explicitly unsupported. +Build and test from the repository root: -`pnpm integration:vscode-host` launches the development extension in pinned VS Code 1.105.1 and -exercises real `PCS001`/cross-clause `PCX003` diagnostics plus Project Index definition. Its first -run downloads that editor test -runtime; this is test infrastructure and does not change the extension's no-server-download policy. +```console +pnpm integration:vscode +pnpm integration:vscode-host +``` + +The real-host gate uses VS Code 1.105.1, opens a Rust buffer, verifies Project Index definition, and +observes shared-engine `PCS001` and `PCX003` diagnostics. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 165405a..b373b9b 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -57,9 +57,10 @@ }, "pliegocss.compiler.path": { "type": "string", - "default": "pliego-cssc", + "default": "", "scope": "machine-overridable", - "description": "Exact path or executable name for the same-version pliego-cssc used by completion and hover." + "description": "Deprecated compatibility setting. Editor analysis is in-process inside pliego-css-lsp.", + "deprecationMessage": "No longer used. Remove this setting; pliego-css-lsp does not launch pliego-cssc." }, "pliegocss.projectIndex.path": { "type": "string", diff --git a/editors/vscode/scripts/check-host.mjs b/editors/vscode/scripts/check-host.mjs index 33f37c3..c6a4d33 100644 --- a/editors/vscode/scripts/check-host.mjs +++ b/editors/vscode/scripts/check-host.mjs @@ -1,17 +1,37 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { resolve, sep } from "node:path"; +import { isAbsolute, join, resolve, sep } from "node:path"; import { runTests } from "@vscode/test-electron"; const EXTENSION = resolve(import.meta.dirname, ".."); const ROOT = resolve(EXTENSION, "..", ".."); -const target = process.env.CARGO_TARGET_DIR - ? resolve(ROOT, process.env.CARGO_TARGET_DIR) +const configuredBase = process.env.PLIEGOCSS_TARGET_BASE ?? process.env.CARGO_TARGET_DIR; +const targetBase = configuredBase + ? (isAbsolute(configuredBase) ? configuredBase : resolve(ROOT, configuredBase)) : resolve(ROOT, "target"); +const rustc = spawnSync("rustup", ["run", "1.85.0", "rustc", "-Vv"], { + encoding: "utf8", + windowsHide: true, +}); +if (rustc.error || rustc.status !== 0) { + throw new Error(`cannot identify Rust 1.85.0: ${rustc.error?.message ?? rustc.stderr}`); +} +const identity = rustc.stdout.replaceAll("\r\n", "\n").trim(); +const release = identity.match(/^release:\s*(.+)$/mu)?.[1] ?? "unknown"; +const host = identity.match(/^host:\s*(.+)$/mu)?.[1] ?? `${process.platform}-${process.arch}`; +/** @param {string} value */ +const safeSegment = (value) => + value.toLowerCase().replaceAll(/[^a-z0-9._-]+/gu, "-").replaceAll(/^-+|-+$/gu, ""); +const targetSegment = `${safeSegment(host)}-rustc-${safeSegment(release)}-${createHash("sha256").update(identity).digest("hex").slice(0, 16)}`; +const target = process.env.PLIEGOCSS_ISOLATED_TARGET === "1" && process.env.CARGO_TARGET_DIR + ? (isAbsolute(process.env.CARGO_TARGET_DIR) + ? process.env.CARGO_TARGET_DIR + : resolve(ROOT, process.env.CARGO_TARGET_DIR)) + : join(targetBase, "toolchains", targetSegment); const workspace = resolve(target, "lsp-integration-workspace"); const executable = process.platform === "win32" ? ".exe" : ""; const lsp = resolve(target, "debug", `pliego-css-lsp${executable}`); -const compiler = resolve(target, "debug", `pliego-cssc${executable}`); /** @param {string} message */ function fail(message) { @@ -22,7 +42,13 @@ if (!workspace.startsWith(`${target}${sep}`)) fail("unsafe VS Code host fixture const fixture = spawnSync(process.execPath, [resolve(ROOT, "scripts", "check-lsp-integration.mjs")], { cwd: ROOT, encoding: "utf8", - env: { ...process.env, PLIEGOCSS_KEEP_LSP_FIXTURE: "1" }, + env: { + ...process.env, + CARGO_TARGET_DIR: target, + PLIEGOCSS_ISOLATED_TARGET: "1", + PLIEGOCSS_KEEP_LSP_FIXTURE: "1", + PLIEGOCSS_TARGET_BASE: targetBase, + }, stdio: ["ignore", "pipe", "pipe"], }); if (fixture.error) fail(`cannot prepare VS Code host fixture: ${fixture.error.message}`); @@ -34,7 +60,6 @@ writeFileSync( `${JSON.stringify( { "pliegocss.server.path": lsp, - "pliegocss.compiler.path": compiler, "pliegocss.theme.mode": "seed", "pliegocss.projectIndex.path": "out/pliego.index.json", }, @@ -68,7 +93,7 @@ process.stdout.write( schemaVersion: 1, vscode: "1.105.1", extension: "celiums.pliegocss-vscode", - diagnostics: "compiler-backed-pcs-and-pcx", + diagnostics: "shared-engine-pcs-and-pcx", definition: "project-index-to-physical-css", serverDownload: false, }, null, 2)}\n`, diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js index 6ceafda..db77a0f 100644 --- a/editors/vscode/src/extension.js +++ b/editors/vscode/src/extension.js @@ -12,7 +12,6 @@ function readSettings() { const configuration = vscode.workspace.getConfiguration("pliegocss"); return { serverPath: configuration.get("server.path", "pliego-css-lsp"), - compilerPath: configuration.get("compiler.path", "pliego-cssc"), projectIndexPath: configuration.get("projectIndex.path", ""), themeMode: configuration.get("theme.mode", "discover"), themeConfig: configuration.get("theme.config", "pliego.theme.toml"), diff --git a/editors/vscode/src/settings.js b/editors/vscode/src/settings.js index 2c09140..d948a84 100644 --- a/editors/vscode/src/settings.js +++ b/editors/vscode/src/settings.js @@ -3,7 +3,7 @@ const THEME_MODES = new Set(["discover", "seed", "config"]); /** - * @typedef {{serverPath:string, compilerPath:string, projectIndexPath:string, themeMode:string, themeConfig:string}} Settings + * @typedef {{serverPath:string, projectIndexPath:string, themeMode:string, themeConfig:string}} Settings */ /** @@ -13,13 +13,11 @@ const THEME_MODES = new Set(["discover", "seed", "config"]); */ function serverInvocation(settings) { const serverPath = settings.serverPath.trim(); - const compilerPath = settings.compilerPath.trim(); if (!serverPath) throw new Error("pliegocss.server.path cannot be empty"); - if (!compilerPath) throw new Error("pliegocss.compiler.path cannot be empty"); if (!THEME_MODES.has(settings.themeMode)) { throw new Error(`unsupported PliegoCSS theme mode: ${settings.themeMode}`); } - const args = ["--pliego-cssc", compilerPath]; + const args = []; const projectIndexPath = settings.projectIndexPath.trim(); if (projectIndexPath) args.push("--project-index", projectIndexPath); if (settings.themeMode === "seed") args.push("--seed"); diff --git a/editors/vscode/tests/settings.test.js b/editors/vscode/tests/settings.test.js index 95305cc..fae20b1 100644 --- a/editors/vscode/tests/settings.test.js +++ b/editors/vscode/tests/settings.test.js @@ -6,28 +6,25 @@ const { serverInvocation } = require("../src/settings"); const defaults = { serverPath: "pliego-css-lsp", - compilerPath: "pliego-cssc", projectIndexPath: "", themeMode: "discover", themeConfig: "pliego.theme.toml", }; -test("discovery keeps an explicit compiler and no hidden theme flag", () => { +test("discovery starts only the in-process language server", () => { assert.deepEqual(serverInvocation(defaults), { command: "pliego-css-lsp", - args: ["--pliego-cssc", "pliego-cssc"], + args: [], }); }); test("seed and config are exact mutually exclusive invocations", () => { assert.deepEqual(serverInvocation({ ...defaults, themeMode: "seed" }).args, [ - "--pliego-cssc", - "pliego-cssc", "--seed", ]); assert.deepEqual( serverInvocation({ ...defaults, themeMode: "config", themeConfig: "themes/app.toml" }).args, - ["--pliego-cssc", "pliego-cssc", "--config", "themes/app.toml"], + ["--config", "themes/app.toml"], ); }); @@ -35,17 +32,14 @@ test("Project Index navigation is explicit and path preserving", () => { assert.deepEqual( serverInvocation({ ...defaults, projectIndexPath: "target/site/assets/pliego.index.json" }).args, [ - "--pliego-cssc", - "pliego-cssc", "--project-index", "target/site/assets/pliego.index.json", ], ); }); -test("empty executables, invalid modes, and empty config fail closed", () => { +test("empty server, invalid modes, and empty config fail closed", () => { assert.throws(() => serverInvocation({ ...defaults, serverPath: " " }), /server\.path/); - assert.throws(() => serverInvocation({ ...defaults, compilerPath: " " }), /compiler\.path/); assert.throws(() => serverInvocation({ ...defaults, themeMode: "automatic" }), /theme mode/); assert.throws( () => serverInvocation({ ...defaults, themeMode: "config", themeConfig: " " }), diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b7aae1b..82cd4a4 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -119,7 +119,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -139,18 +139,18 @@ dependencies = [ [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", diff --git a/integration-tests/manifest-graph/expected.css b/integration-tests/manifest-graph/expected.css index 17ad57e..35c51c5 100644 --- a/integration-tests/manifest-graph/expected.css +++ b/integration-tests/manifest-graph/expected.css @@ -1 +1 @@ -.pc_4sm4s6x04ha0fvxci6gffftk1{padding:1rem;background-color:var(--color-accent)} +.pc_dc4tv3z8js64dcf7c1y2k5tzw{padding:1rem;background-color:var(--color-accent)} diff --git a/integration-tests/manifest-graph/expected.manifest.json b/integration-tests/manifest-graph/expected.manifest.json index a524684..230f3a5 100644 --- a/integration-tests/manifest-graph/expected.manifest.json +++ b/integration-tests/manifest-graph/expected.manifest.json @@ -2,16 +2,16 @@ "schemaVersion": 4, "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, - "themeId": "b3d5ad77175995c2b8f51ef7c0d41991", + "themeIdFormatVersion": 3, + "themeId": "b98b78da29201938d135b8bb94717788", "targets": "modern", "format": "minified", - "cssSha256": "0bde100d7584b36809ee78cf8304f32ff44ede166600813b327c52e366cad5c2", + "cssSha256": "3657abf7c63ba9b0ccaf2428f59a33691fa6fe8d5f155f16a804da9ba46cf770", "cssBytes": 81, "styles": [ { - "styleId": "50fda9c01f9e2dd03aff0d05d8d3ce61", - "className": "pc_4sm4s6x04ha0fvxci6gffftk1", + "styleId": "e1476a2192509ea7a3aae909fe70631c", + "className": "pc_dc4tv3z8js64dcf7c1y2k5tzw", "origins": [ { "source": "p-4 bg-accent", @@ -31,13 +31,13 @@ "applicationCoverage": "adapter-attested-complete", "declarations": [ { - "id": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000000", - "styleId": "50fda9c01f9e2dd03aff0d05d8d3ce61", + "id": "decl:e1476a2192509ea7a3aae909fe70631c:00000000", + "styleId": "e1476a2192509ea7a3aae909fe70631c", "ordinal": 0 }, { - "id": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000001", - "styleId": "50fda9c01f9e2dd03aff0d05d8d3ce61", + "id": "decl:e1476a2192509ea7a3aae909fe70631c:00000001", + "styleId": "e1476a2192509ea7a3aae909fe70631c", "ordinal": 1 } ], @@ -87,21 +87,21 @@ { "kind": "componentUsesDeclaration", "from": "component:card", - "to": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000000" + "to": "decl:e1476a2192509ea7a3aae909fe70631c:00000000" }, { "kind": "componentUsesDeclaration", "from": "component:card", - "to": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000001" + "to": "decl:e1476a2192509ea7a3aae909fe70631c:00000001" }, { "kind": "declarationUsesToken", - "from": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000000", + "from": "decl:e1476a2192509ea7a3aae909fe70631c:00000000", "to": "token:spacing:310ca263" }, { "kind": "declarationUsesToken", - "from": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000001", + "from": "decl:e1476a2192509ea7a3aae909fe70631c:00000001", "to": "token:color:13e3e9b7" }, { @@ -136,13 +136,13 @@ }, { "kind": "styleHasDeclaration", - "from": "style:50fda9c01f9e2dd03aff0d05d8d3ce61", - "to": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000000" + "from": "style:e1476a2192509ea7a3aae909fe70631c", + "to": "decl:e1476a2192509ea7a3aae909fe70631c:00000000" }, { "kind": "styleHasDeclaration", - "from": "style:50fda9c01f9e2dd03aff0d05d8d3ce61", - "to": "decl:50fda9c01f9e2dd03aff0d05d8d3ce61:00000001" + "from": "style:e1476a2192509ea7a3aae909fe70631c", + "to": "decl:e1476a2192509ea7a3aae909fe70631c:00000001" } ] } diff --git a/integration-tests/physical-trace/expected.css b/integration-tests/physical-trace/expected.css index 94134d0..53ba1af 100644 --- a/integration-tests/physical-trace/expected.css +++ b/integration-tests/physical-trace/expected.css @@ -1 +1 @@ -:root{--color-accent:oklch(58% .19 32);--color-accent-strong:oklch(50% .18 30);--color-canvas:oklch(98.5% .004 80);--color-ink:oklch(19% .012 70);--color-line:oklch(88% .008 75);--color-muted:oklch(50% .014 70);--color-surface:#fff;--color-surface-raised:oklch(96% .006 80);--font-mono:ui-monospace,SFMono-Regular,monospace;--font-sans:ui-sans-serif,system-ui,sans-serif}.pc_7trr4palscrsvtq46omh80m6e{--pc-shadow:0 0 #0000;--pc-ring-width:0;--pc-ring-color:currentColor;font-size:.875rem;line-height:1.25rem;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;outline-width:2px;outline-style:solid;--pc-shadow:0 4px 6px -1px #0000001a;--pc-ring-width:2px;--pc-ring-color:color-mix(in oklab,var(--color-accent) 20%,transparent);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);--label:"😀;";color:var(--lightningcss-light,red)var(--lightningcss-dark,#00f);box-shadow:0 0 0 var(--pc-ring-width,0) var(--pc-ring-color,currentColor),var(--pc-shadow,0 0 #0000);opacity:.5!important}@media (width>=48rem){.pc_7trr4palscrsvtq46omh80m6e:hover{background-color:var(--color-accent)}} +:root{--color-accent:oklch(58% .19 32);--color-accent-strong:oklch(50% .18 30);--color-canvas:oklch(98.5% .004 80);--color-ink:oklch(19% .012 70);--color-line:oklch(88% .008 75);--color-muted:oklch(50% .014 70);--color-surface:#fff;--color-surface-raised:oklch(96% .006 80);--font-mono:ui-monospace,SFMono-Regular,monospace;--font-sans:ui-sans-serif,system-ui,sans-serif}.pc_5qli19dn9o0cpcpna4kf91gg8{--pc-shadow:0 0 #0000;--pc-ring-width:0;--pc-ring-color:currentColor;font-size:.875rem;line-height:1.25rem;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;outline-width:2px;outline-style:solid;--pc-shadow:0 4px 6px -1px #0000001a;--pc-ring-width:2px;--pc-ring-color:color-mix(in oklab,var(--color-accent) 20%,transparent);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1);--label:"😀;";color:var(--lightningcss-light,red)var(--lightningcss-dark,#00f);box-shadow:0 0 0 var(--pc-ring-width,0) var(--pc-ring-color,currentColor),var(--pc-shadow,0 0 #0000);opacity:.5!important}@media (width>=48rem){.pc_5qli19dn9o0cpcpna4kf91gg8:hover{background-color:var(--color-accent)}} diff --git a/integration-tests/physical-trace/expected.manifest.json b/integration-tests/physical-trace/expected.manifest.json index 2e37676..2fce786 100644 --- a/integration-tests/physical-trace/expected.manifest.json +++ b/integration-tests/physical-trace/expected.manifest.json @@ -2,16 +2,16 @@ "schemaVersion": 5, "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, - "themeId": "b3d5ad77175995c2b8f51ef7c0d41991", + "themeIdFormatVersion": 3, + "themeId": "b98b78da29201938d135b8bb94717788", "targets": "modern", "format": "minified", - "cssSha256": "6525573b544ae20913a015b8b12b9447532c2110d0f081a7ab36ea08d62c4137", + "cssSha256": "a7cb505f693d93fb6a47aaa3d45c8f028f396f963a63e4c5229d4a62205b62d6", "cssBytes": 1210, "styles": [ { - "styleId": "8434f98c54887df6c28fd869c949bc46", - "className": "pc_7trr4palscrsvtq46omh80m6e", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", + "className": "pc_5qli19dn9o0cpcpna4kf91gg8", "origins": [ { "source": "text-sm antialiased transition-colors outline-2 ring-2 ring-accent/20 shadow-md opacity-50! [color:light-dark(red,blue)] [--label:'😀;'] md:hover:bg-accent", @@ -34,58 +34,58 @@ "physicalCoverage": "compiler-verified-complete", "declarations": [ { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000000", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000000", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 0 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000001", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000001", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 1 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000002", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000002", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 2 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000003", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000003", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 3 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000004", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 4 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000005", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 5 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000006", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 6 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000007", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000007", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 7 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000008", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000008", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 8 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:00000009", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000009", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 9 }, { - "id": "decl:8434f98c54887df6c28fd869c949bc46:0000000a", - "styleId": "8434f98c54887df6c28fd869c949bc46", + "id": "decl:60ef78cd6fb3591f3a1201fbfab38e88:0000000a", + "styleId": "60ef78cd6fb3591f3a1201fbfab38e88", "ordinal": 10 } ], @@ -566,216 +566,216 @@ { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000000" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000000" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000001" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000001" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000002" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000002" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000003" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000003" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000004" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000005" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000006" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000007" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000007" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000008" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000008" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000009" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000009" }, { "kind": "componentUsesDeclaration", "from": "component:app::card", - "to": "decl:8434f98c54887df6c28fd869c949bc46:0000000a" + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:0000000a" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000000", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000000", "to": "css-decl:00000001:00000003" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000000", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000000", "to": "css-decl:00000001:00000004" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000001", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000001", "to": "css-decl:00000001:00000005" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000001", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000001", "to": "css-decl:00000001:00000006" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000002", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000002", "to": "css-decl:00000001:00000007" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000002", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000002", "to": "css-decl:00000001:00000008" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000003", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000003", "to": "css-decl:00000001:00000012" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000004", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", "to": "css-decl:00000001:00000000" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000004", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", "to": "css-decl:00000001:00000001" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000004", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", "to": "css-decl:00000001:00000002" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000004", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", "to": "css-decl:00000001:00000009" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000004", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", "to": "css-decl:00000001:00000011" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000005", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005", "to": "css-decl:00000001:00000000" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000005", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005", "to": "css-decl:00000001:00000001" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000005", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005", "to": "css-decl:00000001:00000002" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000005", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005", "to": "css-decl:00000001:0000000a" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000005", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005", "to": "css-decl:00000001:00000011" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000006", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", "to": "css-decl:00000001:00000000" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000006", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", "to": "css-decl:00000001:00000001" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000006", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", "to": "css-decl:00000001:00000002" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000006", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", "to": "css-decl:00000001:0000000b" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000006", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", "to": "css-decl:00000001:00000011" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000007", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000007", "to": "css-decl:00000001:0000000c" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000007", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000007", "to": "css-decl:00000001:0000000d" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000007", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000007", "to": "css-decl:00000001:0000000e" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000008", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000008", "to": "css-decl:00000001:0000000f" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000009", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000009", "to": "css-decl:00000001:00000010" }, { "kind": "declarationContributesToPhysicalDeclaration", - "from": "decl:8434f98c54887df6c28fd869c949bc46:0000000a", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:0000000a", "to": "css-decl:00000003:00000000" }, { "kind": "declarationUsesToken", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000000", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000000", "to": "token:font-size:4452173d" }, { "kind": "declarationUsesToken", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000004", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004", "to": "token:shadow:672e2914" }, { "kind": "declarationUsesToken", - "from": "decl:8434f98c54887df6c28fd869c949bc46:00000006", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006", "to": "token:color:13e3e9b7" }, { "kind": "declarationUsesToken", - "from": "decl:8434f98c54887df6c28fd869c949bc46:0000000a", + "from": "decl:60ef78cd6fb3591f3a1201fbfab38e88:0000000a", "to": "token:color:13e3e9b7" }, { @@ -945,58 +945,58 @@ }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000000" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000000" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000001" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000001" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000002" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000002" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000003" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000003" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000004" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000004" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000005" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000005" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000006" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000006" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000007" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000007" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000008" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000008" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:00000009" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:00000009" }, { "kind": "styleHasDeclaration", - "from": "style:8434f98c54887df6c28fd869c949bc46", - "to": "decl:8434f98c54887df6c28fd869c949bc46:0000000a" + "from": "style:60ef78cd6fb3591f3a1201fbfab38e88", + "to": "decl:60ef78cd6fb3591f3a1201fbfab38e88:0000000a" }, { "kind": "syntheticProducerProducesPhysicalDeclaration", diff --git a/integration-tests/pliegors-dev-loop/Cargo.lock b/integration-tests/pliegors-dev-loop/Cargo.lock index 97ed556..5c50fd2 100644 --- a/integration-tests/pliegors-dev-loop/Cargo.lock +++ b/integration-tests/pliegors-dev-loop/Cargo.lock @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "pliego-css" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-macros", @@ -277,7 +277,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -287,11 +287,11 @@ dependencies = [ [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-macros" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-compiler", "pliego-css-ir", @@ -304,14 +304,14 @@ dependencies = [ [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", diff --git a/integration-tests/pliegors-smoke/Cargo.lock b/integration-tests/pliegors-smoke/Cargo.lock index b0b99a3..5ca9dff 100644 --- a/integration-tests/pliegors-smoke/Cargo.lock +++ b/integration-tests/pliegors-smoke/Cargo.lock @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "cap-primitives" -version = "4.0.2" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" dependencies = [ "ambient-authority", "fs-set-times", @@ -150,21 +150,21 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -206,9 +206,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "itoa" @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -265,9 +265,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pliego-artifact" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e2e2ab68b4e274215ef7b31819189d17f42b41c0c93f158380952abdd857451" +version = "0.4.0-beta.1" dependencies = [ "cap-fs-ext", "cap-std", @@ -280,7 +278,7 @@ dependencies = [ [[package]] name = "pliego-css" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-macros", @@ -288,7 +286,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -298,18 +296,18 @@ dependencies = [ [[package]] name = "pliego-css-io" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", ] [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-macros" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-compiler", "pliego-css-ir", @@ -322,14 +320,14 @@ dependencies = [ [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] [[package]] name = "pliego-css-source" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", "pliego-css-io", @@ -342,7 +340,7 @@ dependencies = [ [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", @@ -350,9 +348,7 @@ dependencies = [ [[package]] name = "pliego-dom" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf311f3addadbfed7653dbb2ab5360987f55d9e42d3f76f0a0bb0c85db53d7ef" +version = "0.4.0-beta.1" dependencies = [ "js-sys", "pliego-reactive", @@ -362,9 +358,7 @@ dependencies = [ [[package]] name = "pliego-macros" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aa6e1c4e1fc0a45f4d19e0397bea933723b4328e7c977bb0d9ea3413136c9b" +version = "0.4.0-beta.1" dependencies = [ "proc-macro2", "quote", @@ -374,15 +368,11 @@ dependencies = [ [[package]] name = "pliego-reactive" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97562ecb0ec7d6165178d1a2a5403dcb084060d9252b1ca62b251d4fa75dc62d" +version = "0.4.0-beta.1" [[package]] name = "pliego-resume" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae3bbaae6a955fc8a763eb75b07d8a43b0a4533533e7c50842657d8be80f07b" +version = "0.4.0-beta.1" dependencies = [ "pliego-dom", "serde_json", @@ -390,15 +380,14 @@ dependencies = [ [[package]] name = "pliego-ssg" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0358ddefc33312fed302f92d78c8ccf2b0e5384c7eadf5ef0b88964ae0856f06" +version = "0.4.0-beta.1" dependencies = [ "cap-fs-ext", "cap-std", "fs2", "pliego-artifact", "pliego-dom", + "serde", "serde_json", ] @@ -548,7 +537,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] @@ -594,9 +583,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -617,22 +606,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] diff --git a/integration-tests/pliegors-smoke/Cargo.toml b/integration-tests/pliegors-smoke/Cargo.toml index d24bfd8..6207cfb 100644 --- a/integration-tests/pliegors-smoke/Cargo.toml +++ b/integration-tests/pliegors-smoke/Cargo.toml @@ -28,10 +28,10 @@ path = "src/bin/collector.rs" [dependencies] pliego-css = { path = "../../crates/pliego-css" } pliego-css-source = { path = "../../crates/pliego-css-source" } -pliego-dom = "=0.0.2" -pliego-macros = "=0.0.2" -pliego-resume = "=0.0.2" -pliego-ssg = "=0.0.2" +pliego-dom = { version = "=0.4.0-beta.1", path = "../../../pliegors/crates/pliego-dom" } +pliego-macros = { version = "=0.4.0-beta.1", path = "../../../pliegors/crates/pliego-macros" } +pliego-resume = { version = "=0.4.0-beta.1", path = "../../../pliegors/crates/pliego-resume" } +pliego-ssg = { version = "=0.4.0-beta.1", path = "../../../pliegors/crates/pliego-ssg" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10" diff --git a/integration-tests/pliegors-smoke/pliegors-contract.json b/integration-tests/pliegors-smoke/pliegors-contract.json index cecd44e..074531d 100644 --- a/integration-tests/pliegors-smoke/pliegors-contract.json +++ b/integration-tests/pliegors-smoke/pliegors-contract.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "revision": "f3f4eb9be7ba2abfb7ed952675bba062d375e1fe", - "sourceSha256": "a11ca98bf64a93486da1d0a3478cf73920b7105608c55d215d4e3e5d0c817373", + "revision": "abb8653e75da4a5cb4dd9b51200114fbb1e760c7", + "sourceSha256": "2ba965b8d72831e3f452d91a5ba98d7297638fac661bcf1a2f7edb8ca1da68fa", "surfaces": [ "Cargo.toml", "Cargo.lock", @@ -11,6 +11,7 @@ "crates/pliego-resume/**", "crates/pliego-ssg/**", "crates/pliego-starters/**", - "crates/pliego-cli/**" + "crates/pliego-cli/**", + "schemas/pliego.product-topology.schema.json" ] } diff --git a/integration-tests/pliegors-smoke/src/bin/collector.rs b/integration-tests/pliegors-smoke/src/bin/collector.rs index 17d6e5d..306f805 100644 --- a/integration-tests/pliegors-smoke/src/bin/collector.rs +++ b/integration-tests/pliegors-smoke/src/bin/collector.rs @@ -5,9 +5,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use pliegocss_pliegors_smoke::css_adapter::{ - GeneratedBundle, generate_css_inputs_from_inventory, -}; +use pliego_css_source::ProductBundle; +use pliegocss_pliegors_smoke::css_adapter::generate_css_inputs_from_inventory; use pliegocss_pliegors_smoke::product::application_registry; #[derive(Serialize)] @@ -20,7 +19,7 @@ struct CollectorSummary<'a> { style_sites: usize, reachability_bytes: usize, bundle_plan_bytes: usize, - bundles: &'a [GeneratedBundle], + bundles: &'a [ProductBundle], } #[derive(Deserialize)] diff --git a/integration-tests/pliegors-smoke/src/bin/ssg.rs b/integration-tests/pliegors-smoke/src/bin/ssg.rs index 4691887..3bbe3f8 100644 --- a/integration-tests/pliegors-smoke/src/bin/ssg.rs +++ b/integration-tests/pliegors-smoke/src/bin/ssg.rs @@ -371,7 +371,7 @@ fn validate_plan(plan: &AssetPlan) -> Result<(), Box> { || plan.application_coverage != "adapter-attested-complete" || plan.style_id_format_version != 2 || plan.class_name_format_version != 1 - || plan.theme_id_format_version != 2 + || plan.theme_id_format_version != 3 || !lower_hex(&plan.theme_id, 32) || plan.targets != "modern" || plan.format != "minified" diff --git a/integration-tests/pliegors-smoke/src/css_adapter.rs b/integration-tests/pliegors-smoke/src/css_adapter.rs index 0b486af..fc5f41b 100644 --- a/integration-tests/pliegors-smoke/src/css_adapter.rs +++ b/integration-tests/pliegors-smoke/src/css_adapter.rs @@ -1,32 +1,17 @@ //! PliegoCSS adapter generated from the framework-owned PliegoRS product registry. -use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::path::Path; -use pliego_css_source::{ - ApplicationComponent, ApplicationIsland, ApplicationRoute, ApplicationTopology, - CollectedReachability, -}; +use pliego_css_source::{CollectedReachability, ProductBundle, ProductTopology}; use pliego_ssg::ProductRegistry; -use serde::Serialize; -use sha2::{Digest, Sha256}; - -/// One automatically derived physical CSS partition. -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GeneratedBundle { - pub id: String, - pub sources: Vec, - pub emit_theme: bool, -} /// Reachability and bundle-plan bytes derived from one immutable product registry. #[derive(Debug)] pub struct GeneratedCssInputs { pub reachability: CollectedReachability, pub bundle_plan: Vec, - pub bundles: Vec, + pub bundles: Vec, } /// Failure to convert or collect one product registry. @@ -35,12 +20,6 @@ pub struct AdapterError { message: String, } -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -enum Root { - Route(String), - Island(String), -} - /// Generates canonical reachability and an automatic source partition from one product registry. pub fn generate_css_inputs( registry: &ProductRegistry, @@ -66,13 +45,13 @@ fn generate_css_inputs_internal( project_root: &Path, source_units: Option<&[String]>, ) -> Result { - registry.validate().map_err(|error| invalid(error.to_string()))?; - let topology = topology(registry, source_units); - let reachability = topology - .collect(project_root) + let topology_bytes = registry.to_topology_json().map_err(|error| invalid(error.to_string()))?; + let topology = ProductTopology::from_json(&topology_bytes) + .map_err(|error| invalid(error.to_string()))?; + let generated = topology + .collect_css_inputs(project_root, source_units) .map_err(|error| invalid(error.to_string()))?; - let bundles = partition(registry)?; - let bundle_plan = render_bundle_plan(&bundles)?; + let (reachability, bundle_plan, bundles) = generated.into_parts(); Ok(GeneratedCssInputs { reachability, bundle_plan, @@ -88,189 +67,6 @@ impl fmt::Display for AdapterError { impl std::error::Error for AdapterError {} -fn topology(registry: &ProductRegistry, inventory: Option<&[String]>) -> ApplicationTopology { - let mut topology = ApplicationTopology::new(); - let mut source_roots = BTreeSet::new(); - for component in registry.components() { - let mut application_component = ApplicationComponent::new(component.id()); - for source in component.source_units() { - application_component = application_component.source_unit(source); - let path = Path::new(source); - let root = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .and_then(Path::to_str) - .unwrap_or(source) - .replace('\\', "/"); - source_roots.insert(root); - } - topology = topology.component(application_component); - } - if let Some(inventory) = inventory { - source_roots = inventory.iter().cloned().collect(); - } - for source_root in source_roots { - topology = topology.source_root(source_root); - } - for route in registry.routes() { - let mut application_route = ApplicationRoute::new(route.id(), route.path()); - for component in route.components() { - application_route = application_route.component(component); - } - topology = topology.route(application_route); - } - for island in registry.islands() { - let mut application_island = ApplicationIsland::new(island.id(), island.name()); - for component in island.components() { - application_island = application_island.component(component); - } - topology = topology.island(application_island); - } - topology -} - -fn partition(registry: &ProductRegistry) -> Result, AdapterError> { - let mut memberships = registry - .components() - .iter() - .map(|component| (component.id(), BTreeSet::new())) - .collect::>(); - for route in registry.routes() { - for component in route.components() { - memberships - .get_mut(component.as_str()) - .ok_or_else(|| invalid(format!("unknown route component `{component}`")))? - .insert(Root::Route(route.id().to_owned())); - } - } - for island in registry.islands() { - for component in island.components() { - memberships - .get_mut(component.as_str()) - .ok_or_else(|| invalid(format!("unknown island component `{component}`")))? - .insert(Root::Island(island.id().to_owned())); - } - } - - let mut sources = BTreeMap::>::new(); - for component in registry.components() { - let roots = memberships - .get(component.id()) - .ok_or_else(|| invalid(format!("component `{}` has no membership", component.id())))?; - for source in component.source_units() { - sources - .entry(source.clone()) - .or_default() - .extend(roots.iter().cloned()); - } - } - - let mut groups = BTreeMap::, BTreeSet>::new(); - for (source, roots) in sources { - groups.entry(roots).or_default().insert(source); - } - let route_ids = registry - .routes() - .iter() - .map(|route| route.id().to_owned()) - .collect::>(); - let mut bundles = BTreeMap::new(); - let mut universal = Vec::new(); - for (roots, sources) in groups { - let id = bundle_id(&roots, &route_ids); - if bundles.contains_key(&id) { - return Err(invalid(format!("automatic bundle ID collision `{id}`"))); - } - if route_ids - .iter() - .all(|route| roots.contains(&Root::Route(route.clone()))) - { - universal.push(id.clone()); - } - bundles.insert( - id.clone(), - GeneratedBundle { - id, - sources: sources.into_iter().collect(), - emit_theme: false, - }, - ); - } - universal.sort(); - let theme_bundle = universal - .first() - .ok_or_else(|| invalid("automatic partition requires one bundle shared by every route"))?; - bundles - .get_mut(theme_bundle) - .expect("selected theme bundle came from the bundle map") - .emit_theme = true; - Ok(bundles.into_values().collect()) -} - -fn bundle_id(roots: &BTreeSet, route_ids: &BTreeSet) -> String { - if roots.is_empty() { - return "unreachable".to_owned(); - } - let routes = roots - .iter() - .filter_map(|root| match root { - Root::Route(id) => Some(id.as_str()), - Root::Island(_) => None, - }) - .collect::>(); - let islands = roots - .iter() - .filter_map(|root| match root { - Root::Island(id) => Some(id.as_str()), - Root::Route(_) => None, - }) - .collect::>(); - if islands.is_empty() - && routes.len() == route_ids.len() - && routes.iter().all(|route| route_ids.contains(*route)) - { - return "shared".to_owned(); - } - if routes.len() == 1 && islands.is_empty() { - return format!("route-{}", routes[0]); - } - if routes.is_empty() && islands.len() == 1 { - return format!("island-{}", islands[0]); - } - let mut digest = Sha256::new(); - for root in roots { - match root { - Root::Route(id) => digest.update(format!("route\0{id}\0")), - Root::Island(id) => digest.update(format!("island\0{id}\0")), - } - } - format!("shared-{}", &format!("{:x}", digest.finalize())[..12]) -} - -fn render_bundle_plan(bundles: &[GeneratedBundle]) -> Result, AdapterError> { - let mut output = String::from( - "schema = 1\ntargets = \"modern\"\nformat = \"minified\"\n\n[theme]\nkind = \"seed\"\n", - ); - for bundle in bundles { - output.push_str("\n[bundles."); - output.push_str(&bundle.id); - output.push_str("]\nsources = ["); - for (index, source) in bundle.sources.iter().enumerate() { - if index > 0 { - output.push_str(", "); - } - output.push_str( - &serde_json::to_string(source) - .map_err(|error| invalid(format!("cannot encode bundle source: {error}")))?, - ); - } - output.push_str("]\nemit-theme = "); - output.push_str(if bundle.emit_theme { "true" } else { "false" }); - output.push('\n'); - } - Ok(output.into_bytes()) -} - fn invalid(message: impl Into) -> AdapterError { AdapterError { message: message.into(), @@ -279,6 +75,8 @@ fn invalid(message: impl Into) -> AdapterError { #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use super::*; use crate::product::application_registry; @@ -295,7 +93,7 @@ mod tests { generated .bundles .iter() - .map(|bundle| bundle.id.as_str()) + .map(ProductBundle::id) .collect::>(), [ "island-visit-counter", @@ -309,8 +107,8 @@ mod tests { generated .bundles .iter() - .filter(|bundle| bundle.emit_theme) - .map(|bundle| bundle.id.as_str()) + .filter(|bundle| bundle.emits_theme()) + .map(ProductBundle::id) .collect::>(), ["shared"] ); diff --git a/integration-tests/pliegors-smoke/src/product.rs b/integration-tests/pliegors-smoke/src/product.rs index 86f4cb3..deeacfb 100644 --- a/integration-tests/pliegors-smoke/src/product.rs +++ b/integration-tests/pliegors-smoke/src/product.rs @@ -62,4 +62,12 @@ mod tests { && component.source_units()[0].starts_with("src/styles/") })); } + + #[test] + fn product_registry_emits_the_native_topology_seam() { + let bytes = application_registry().to_topology_json().unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["schema"], "pliegors-product-topology/1"); + assert_eq!(value["routes"][1]["islands"][0], VISIT_COUNTER_ID); + } } diff --git a/integration-tests/public-api-smoke/Cargo.lock b/integration-tests/public-api-smoke/Cargo.lock index 55abc0e..7915bb6 100644 --- a/integration-tests/public-api-smoke/Cargo.lock +++ b/integration-tests/public-api-smoke/Cargo.lock @@ -371,7 +371,7 @@ dependencies = [ [[package]] name = "pliego-css" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-macros", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "pliego-css-agent" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-build", "pliego-css-config", @@ -390,7 +390,7 @@ dependencies = [ [[package]] name = "pliego-css-build" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "cssparser", "lightningcss", @@ -405,7 +405,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -415,7 +415,7 @@ dependencies = [ [[package]] name = "pliego-css-config" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "cssparser", "libc", @@ -431,18 +431,18 @@ dependencies = [ [[package]] name = "pliego-css-io" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", ] [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-macros" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-compiler", "pliego-css-ir", @@ -455,7 +455,7 @@ dependencies = [ [[package]] name = "pliego-css-ownership" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "serde", "serde_json", @@ -464,7 +464,7 @@ dependencies = [ [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "pliego-css-publication" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "fs2", "libc", @@ -492,7 +492,7 @@ dependencies = [ [[package]] name = "pliego-css-source" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", "pliego-css-io", @@ -505,7 +505,7 @@ dependencies = [ [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", @@ -513,7 +513,7 @@ dependencies = [ [[package]] name = "pliego-css-usage" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-build", "pliego-css-compiler", diff --git a/integration-tests/public-api-smoke/Cargo.toml b/integration-tests/public-api-smoke/Cargo.toml index 362a2fa..3d04592 100644 --- a/integration-tests/public-api-smoke/Cargo.toml +++ b/integration-tests/public-api-smoke/Cargo.toml @@ -6,14 +6,14 @@ rust-version = "1.85" publish = false [dependencies] -css = { package = "pliego-css", path = "../../crates/pliego-css", version = "=0.1.0-rc.2" } -pliego-css-agent = { path = "../../crates/pliego-css-agent", version = "=0.1.0-rc.2" } -pliego-css-ownership = { path = "../../crates/pliego-css-ownership", version = "=0.1.0-rc.2" } -pliego-css-source = { path = "../../crates/pliego-css-source", version = "=0.1.0-rc.2" } -pliego-css-usage = { path = "../../crates/pliego-css-usage", version = "=0.1.0-rc.2" } +css = { package = "pliego-css", path = "../../crates/pliego-css", version = "=0.1.0-rc.3" } +pliego-css-agent = { path = "../../crates/pliego-css-agent", version = "=0.1.0-rc.3" } +pliego-css-ownership = { path = "../../crates/pliego-css-ownership", version = "=0.1.0-rc.3" } +pliego-css-source = { path = "../../crates/pliego-css-source", version = "=0.1.0-rc.3" } +pliego-css-usage = { path = "../../crates/pliego-css-usage", version = "=0.1.0-rc.3" } [build-dependencies] -pliego-css-build = { path = "../../crates/pliego-css-build", version = "=0.1.0-rc.2" } +pliego-css-build = { path = "../../crates/pliego-css-build", version = "=0.1.0-rc.3" } [workspace] resolver = "2" diff --git a/integration-tests/public-api-smoke/src/main.rs b/integration-tests/public-api-smoke/src/main.rs index 8f80f08..99098a9 100644 --- a/integration-tests/public-api-smoke/src/main.rs +++ b/integration-tests/public-api-smoke/src/main.rs @@ -32,8 +32,8 @@ use pliego_css_source::{ MigrationDependencyKind, MigrationDependencyResolution, MigrationDisposition, MigrationInventory, MigrationInventoryError, MigrationPreflightReliance, MigrationProject, MigrationProjectAuxiliary, MigrationProjectConsumer, MigrationProjectInventory, - MigrationProjectSource, MigrationSourceKind, discover_migration_project, - inspect_utility_format, scan_source_named, + MigrationProjectSource, MigrationSourceKind, PRODUCT_TOPOLOGY_SCHEMA, ProductBundle, + ProductTopology, discover_migration_project, inspect_utility_format, scan_source_named, inventory_migration_auxiliary_file, inventory_migration_auxiliary_source, inventory_migration_consumer_file, inventory_migration_consumer_source, inventory_migration_file, inventory_migration_source, @@ -59,7 +59,7 @@ const OWNERSHIP_ASSET_PLAN: &[u8] = br#"{ "applicationCoverage": "adapter-attested-complete", "styleIdFormatVersion": 2, "classNameFormatVersion": 1, - "themeIdFormatVersion": 2, + "themeIdFormatVersion": 3, "themeId": "00000000000000000000000000000000", "targets": "none", "format": "minified", @@ -461,6 +461,31 @@ fn exercise_application_collector_surface() { })); } +fn exercise_product_topology_surface() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let document = format!( + r#"{{"schema":"{PRODUCT_TOPOLOGY_SCHEMA}","components":[{{"id":"public-api-smoke::main","sourceUnits":["src/main.rs"]}}],"routes":[{{"id":"home","path":"/","components":["public-api-smoke::main"],"islands":[]}}],"islands":[]}}"#, + ); + let topology = ProductTopology::from_json(document.as_bytes()) + .expect("public product topology must decode"); + let generated = topology + .collect_css_inputs(root, None) + .expect("public product topology must collect"); + assert!(generated.reachability().as_bytes().ends_with(b"\n")); + assert!(generated.bundle_plan().ends_with(b"\n")); + assert_eq!( + generated + .bundles() + .iter() + .map(ProductBundle::id) + .collect::>(), + ["shared"] + ); + assert!(generated.bundles()[0].emits_theme()); + assert_eq!(generated.bundles()[0].sources(), ["src/main.rs"]); + let _parts = generated.into_parts(); +} + fn exercise_source_formatting_surface() { let inspection = inspect_utility_format(&[], |value| Ok::<_, String>(value.to_owned())) .expect("empty source-format inspection"); @@ -483,6 +508,7 @@ fn main() { exercise_ownership_adapter_surface(); exercise_usage_adapter_surface(); exercise_application_collector_surface(); + exercise_product_topology_surface(); exercise_source_formatting_surface(); exercise_typed_parse_diagnostic_surface(); require_style_traits::`, + ); +} + +function normalizeComputed(value) { + return value + .replaceAll(/\s+/gu, " ") + .replaceAll("flex-end", "end") + .trim(); +} + +async function browserSnapshot(browser, document, css, viewport, properties) { + const page = await browser.newPage({ viewport }); + const consoleErrors = []; + const pageErrors = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("pageerror", (error) => pageErrors.push(error.message)); + try { + await page.setContent(injectCss(document, css), { waitUntil: "load" }); + const dom = await page.evaluate(() => { + function project(node) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent.replace(/\s+/gu, " ").trim(); + return text ? { text } : null; + } + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const attributes = [...node.attributes] + .filter((attribute) => attribute.name !== "class" && attribute.name !== "data-g6-node") + .map((attribute) => [attribute.name, attribute.value]) + .sort(([left], [right]) => left.localeCompare(right, "en")); + return { + tag: node.tagName.toLowerCase(), + attributes, + children: [...node.childNodes].map(project).filter(Boolean), + }; + } + return project(document.body); + }); + const aria = await page.locator("body").ariaSnapshot(); + const nodes = await page.locator("[data-g6-node]").evaluateAll( + (elements, frozenProperties) => + elements.map((element) => { + const style = getComputedStyle(element); + const rectangle = element.getBoundingClientRect(); + return { + id: element.getAttribute("data-g6-node"), + computed: Object.fromEntries( + frozenProperties.map((property) => [property, style.getPropertyValue(property)]), + ), + geometry: { + x: rectangle.x, + y: rectangle.y, + width: rectangle.width, + height: rectangle.height, + }, + }; + }), + properties, + ); + if (consoleErrors.length || pageErrors.length) { + fail(`browser emitted errors: ${JSON.stringify({ consoleErrors, pageErrors })}`); + } + return { dom, aria, nodes }; + } finally { + await page.close(); + } +} + +function compareSnapshots(project, baseline, candidate, maximumGeometryDelta) { + if (JSON.stringify(baseline.dom) !== JSON.stringify(candidate.dom)) { + fail(`${project.id} DOM semantics changed`); + } + if (baseline.aria !== candidate.aria) fail(`${project.id} ARIA snapshot changed`); + if (baseline.nodes.length !== candidate.nodes.length || baseline.nodes.length === 0) { + fail(`${project.id} class-bearing node inventory changed`); + } + let maximumObservedGeometryDelta = 0; + for (let index = 0; index < baseline.nodes.length; index += 1) { + const left = baseline.nodes[index]; + const right = candidate.nodes[index]; + if (left.id !== right.id) fail(`${project.id} node identity changed at ${index}`); + for (const property of Object.keys(left.computed)) { + const expected = normalizeComputed(left.computed[property]); + const actual = normalizeComputed(right.computed[property]); + if (expected !== actual) { + fail( + `${project.id} computed ${property} changed on ${left.id}: ${JSON.stringify(expected)} != ${JSON.stringify(actual)}`, + ); + } + } + for (const coordinate of ["x", "y", "width", "height"]) { + const delta = Math.abs(left.geometry[coordinate] - right.geometry[coordinate]); + maximumObservedGeometryDelta = Math.max(maximumObservedGeometryDelta, delta); + if (delta > maximumGeometryDelta) { + fail(`${project.id} geometry ${coordinate} changed by ${delta} CSS px on ${left.id}`); + } + } + } + return { + domEquivalent: true, + ariaEquivalent: true, + computedStyleEquivalent: true, + geometryEquivalent: true, + nodes: baseline.nodes.length, + maximumObservedGeometryDeltaCssPx: maximumObservedGeometryDelta, + ariaSha256: sha256(Buffer.from(baseline.aria)), + domSha256: sha256(Buffer.from(JSON.stringify(baseline.dom))), + }; +} + +function instant(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/u, "Z"); +} + +const options = parseOptions(process.argv.slice(2)); +const contract = loadContract(); +const host = hostFor(contract.authority, options.browser, options.host); +options.output ??= resolve( + repositoryRoot, + "target", + "adapter-coexistence-certification", + `${host.id}.local.json`, +); +const outputChild = relative(repositoryRoot, options.output); +if ( + !outputChild || + outputChild === ".." || + outputChild.startsWith(`..${sep}`) || + isAbsolute(outputChild) || + !options.output.endsWith(".json") +) { + fail("--output must be a .json path inside the repository"); +} + +const source = gitState(options.requireClean); +const compiler = prepareCompiler(); +const resetBytes = readFileSync(resolve(repositoryRoot, contract.authority.sharedReset.path)); +const workRoot = resolve( + repositoryRoot, + "target", + "adapter-coexistence-certification", + `work-${process.pid}-${Date.now()}`, +); +mkdirSync(workRoot, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +const startedAt = instant(); +const projects = []; +try { + for (const project of contract.corpus.projects) { + process.stdout.write(`[adapter-coexistence] ${project.id}\n`); + const stage = join(workRoot, project.id); + mkdirSync(stage, { recursive: true }); + const document = decorateClassNodes(renderProjectDocument(project), project.id); + writeFileSync(join(stage, "index.html"), document); + const profile = profileById(contract.authority, project.tailwindProfile); + const tailwind = compileTailwind(profile, stage); + const sourceInventory = inventoryTailwindSource(compiler, stage, profile); + const audit = auditTailwind(compiler, stage); + const pliego = compilePliego(compiler, stage, document); + const baselineSource = { + document, + css: Buffer.concat([resetBytes, tailwind.output]), + }; + let baselineBuild = null; + if (project.adapter === "vite") { + writeFileSync(join(stage, "pliego.css"), Buffer.alloc(0)); + baselineBuild = await viteBuildSnapshot(stage, "dist-baseline"); + } + const migration = prepareMigration(compiler, stage, document, pliego); + let candidateBuild = null; + if (project.adapter === "vite") candidateBuild = await viteBuildSnapshot(stage, "dist-candidate"); + const baselineDocument = baselineBuild?.html ?? baselineSource.document; + const baselineCss = baselineBuild + ? Buffer.concat([resetBytes, baselineBuild.css]) + : baselineSource.css; + const candidateDocument = candidateBuild?.html ?? migration.after.document.toString("utf8"); + const candidateCss = candidateBuild + ? Buffer.concat([resetBytes, candidateBuild.css]) + : Buffer.concat([resetBytes, migration.after.tailwind, migration.after.pliego]); + const baselineSnapshot = await browserSnapshot( + browser, + baselineDocument, + baselineCss, + project.viewport, + contract.authority.comparison.computedProperties, + ); + const candidateSnapshot = await browserSnapshot( + browser, + candidateDocument, + candidateCss, + project.viewport, + contract.authority.comparison.computedProperties, + ); + const equivalence = compareSnapshots( + project, + baselineSnapshot, + candidateSnapshot, + contract.authority.comparison.maximumLayoutGeometryDeltaCssPx, + ); + const rollback = rollbackMigration(compiler, stage, migration); + projects.push({ + id: project.id, + adapter: project.adapter, + tailwindProfile: project.tailwindProfile, + viewport: project.viewport, + source: { + documentSha256: sha256(Buffer.from(document)), + classGroups: extractStaticClassGroups(document, project.id).groups.length, + }, + tailwind: { + version: profile.version, + inputSha256: tailwind.inputSha256, + outputSha256: tailwind.outputSha256, + outputBytes: tailwind.outputBytes, + sourceInventory, + audit, + }, + pliego: { + outputSha256: pliego.cssSha256, + outputBytes: pliego.css.length, + manifestSha256: pliego.manifestSha256, + styleGroups: pliego.styleGroups, + }, + migration: { + mode: contract.authority.migration.mode, + replacements: migration.rewrite.replacements, + groupSha256: migration.groupSha256, + receiptSha256: migration.receiptSha256, + receiptSchemaVersion: migration.receipt.schemaVersion, + beforeDocumentSha256: sha256(migration.before.document), + afterDocumentSha256: sha256(migration.after.document), + tailwindRetained: sha256(migration.before.tailwind) === sha256(migration.after.tailwind), + rollback, + }, + vite: + project.adapter === "vite" + ? { + version: contract.authority.adapters.find((entry) => entry.id === "vite").version, + baseline: { + htmlSha256: baselineBuild.htmlSha256, + cssSha256: baselineBuild.cssSha256, + assets: baselineBuild.assets, + }, + candidate: { + htmlSha256: candidateBuild.htmlSha256, + cssSha256: candidateBuild.cssSha256, + assets: candidateBuild.assets, + }, + } + : null, + equivalence, + passed: true, + }); + } +} finally { + await browser.close(); +} + +const completedAt = instant(); +const adapterCounts = Object.fromEntries( + contract.authority.adapters.map((adapter) => [ + adapter.id, + projects.filter((project) => project.adapter === adapter.id).length, + ]), +); +const profileCounts = Object.fromEntries( + contract.authority.tailwindProfiles.map((profile) => [ + profile.id, + projects.filter((project) => project.tailwindProfile === profile.id).length, + ]), +); +const evidence = { + schemaVersion: 1, + kind: "pliegocss-adapter-coexistence-host-evidence", + result: projects.length >= contract.authority.corpus.minimumProjects ? "pass" : "fail", + startedAt, + completedAt, + source, + authority: { + path: relative(repositoryRoot, authorityPath).replaceAll("\\", "/"), + sha256: contract.authoritySha256, + corpusPath: relative(repositoryRoot, contract.corpusPath).replaceAll("\\", "/"), + corpusSha256: contract.corpusSha256, + }, + host: { + ...host, + browserVersion: browser.version(), + playwright: readJson(resolve(repositoryRoot, "node_modules", "playwright", "package.json")).version, + }, + compiler, + summary: { + projects: projects.length, + passed: projects.filter((project) => project.passed).length, + adapters: adapterCounts, + tailwindProfiles: profileCounts, + domEquivalent: projects.filter((project) => project.equivalence.domEquivalent).length, + ariaEquivalent: projects.filter((project) => project.equivalence.ariaEquivalent).length, + computedStyleEquivalent: projects.filter( + (project) => project.equivalence.computedStyleEquivalent, + ).length, + geometryEquivalent: projects.filter((project) => project.equivalence.geometryEquivalent).length, + exactRollbacks: projects.filter((project) => project.migration.rollback.exact).length, + }, + projects, + claimBoundary: contract.authority.claimBoundary, +}; +if (evidence.result !== "pass") fail("project floor was not met"); +mkdirSync(dirname(options.output), { recursive: true }); +writeFileSync(options.output, `${JSON.stringify(evidence, null, 2)}\n`); +rmSync(workRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +process.stdout.write( + `${JSON.stringify( + { + passed: true, + host: host.id, + projects: projects.length, + adapters: adapterCounts, + tailwindProfiles: profileCounts, + output: relative(repositoryRoot, options.output).replaceAll("\\", "/"), + }, + null, + 2, + )}\n`, +); diff --git a/scripts/adapter-coexistence-v1.mjs b/scripts/adapter-coexistence-v1.mjs new file mode 100644 index 0000000..14bda8c --- /dev/null +++ b/scripts/adapter-coexistence-v1.mjs @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +export const authorityPath = resolve( + repositoryRoot, + "benchmarks", + "adapter-coexistence-v1", + "authority.json", +); + +export function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +export function loadContract() { + const authorityBytes = readFileSync(authorityPath); + const authority = JSON.parse(authorityBytes.toString("utf8")); + const corpusPath = resolve(repositoryRoot, authority.corpus.path); + const corpusBytes = readFileSync(corpusPath); + if (sha256(corpusBytes) !== authority.corpus.sha256) { + throw new Error("adapter coexistence corpus hash drifted"); + } + return { + authority, + authorityBytes, + authoritySha256: sha256(authorityBytes), + corpus: JSON.parse(corpusBytes.toString("utf8")), + corpusBytes, + corpusPath, + corpusSha256: sha256(corpusBytes), + }; +} + +export function renderProjectDocument(project) { + return `\n\n\n\n\n${escapeText(project.title)}\n\n\n\n${project.body}\n\n`; +} + +function escapeText(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +const classAttribute = /\bclass="([^"]+)"/gu; + +export function extractStaticClassGroups(document, role = "document") { + for (const forbidden of [ + /\bclassName\s*=/u, + /\bclass\s*=\s*'/u, + /(?:^|\s)(?::class|v-bind:class|\[class\]|class:list)\s*=/u, + /\$\{|\{\{|<%|#\{/u, + /<(?:script|style)\b/iu, + ]) { + if (forbidden.test(document)) { + throw new Error(`${role} contains unsupported or dynamic class syntax ${forbidden}`); + } + } + const matches = [...document.matchAll(classAttribute)]; + const rawClassMarkers = document.match(/\bclass\s*=/gu)?.length ?? 0; + if (matches.length === 0 || matches.length !== rawClassMarkers) { + throw new Error(`${role} must use only non-empty double-quoted class attributes`); + } + const groups = matches.map((match) => { + const normalized = match[1].trim().split(/\s+/u).join(" "); + if (normalized !== match[1]) { + throw new Error(`${role} class groups must already use canonical single spacing`); + } + return normalized; + }); + return { groups, uniqueGroups: [...new Set(groups)] }; +} + +export function decorateClassNodes(document, role = "document") { + let index = 0; + const decorated = document.replace(classAttribute, (attribute) => { + const node = `g6-node-${String(index).padStart(3, "0")}`; + index += 1; + return `data-g6-node="${node}" ${attribute}`; + }); + if (index !== extractStaticClassGroups(document, role).groups.length) { + throw new Error(`${role} class decoration count drifted`); + } + return decorated; +} + +export function rewriteStaticClassGroups(document, manifest, role = "document") { + const mappings = new Map(); + for (const style of manifest.styles ?? []) { + if (typeof style.className !== "string" || !style.className.startsWith("pc_")) { + throw new Error(`${role} manifest contains an invalid generated class`); + } + for (const origin of style.origins ?? []) { + if (typeof origin.source !== "string") continue; + const previous = mappings.get(origin.source); + if (previous && previous !== style.className) { + throw new Error(`${role} maps one source class group to multiple generated classes`); + } + mappings.set(origin.source, style.className); + } + } + let replacements = 0; + const rewritten = document.replace(classAttribute, (_, group) => { + const generated = mappings.get(group); + if (!generated) { + throw new Error(`${role} manifest has no generated class for ${JSON.stringify(group)}`); + } + replacements += 1; + return `class="${generated}"`; + }); + const expected = extractStaticClassGroups(document, role).groups.length; + if (replacements !== expected) throw new Error(`${role} did not rewrite every class group`); + return { document: rewritten, mappings, replacements }; +} + +export function exactKeys(value, expected, role) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${role} must be an object`); + } + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + throw new Error(`${role} keys drifted: ${actual.join(", ")}`); + } +} + +export function profileById(authority, id) { + const profile = authority.tailwindProfiles.find((entry) => entry.id === id); + if (!profile) throw new Error(`unknown Tailwind profile ${id}`); + return profile; +} + +export function hostFor(authority, browser, requestedId) { + const host = requestedId + ? authority.hosts.find((entry) => entry.id === requestedId) + : authority.hosts.find( + (entry) => + entry.browser === browser && entry.os === process.platform && entry.arch === process.arch, + ); + if (!host) throw new Error("current host is outside the adapter coexistence authority"); + if (host.browser !== browser || host.os !== process.platform || host.arch !== process.arch) { + throw new Error( + `host ${host.id} requires ${host.os}/${host.arch}/${host.browser}, found ${process.platform}/${process.arch}/${browser}`, + ); + } + return host; +} diff --git a/scripts/adapter-coexistence-v1.test.mjs b/scripts/adapter-coexistence-v1.test.mjs new file mode 100644 index 0000000..137eebb --- /dev/null +++ b/scripts/adapter-coexistence-v1.test.mjs @@ -0,0 +1,72 @@ +import { + decorateClassNodes, + extractStaticClassGroups, + rewriteStaticClassGroups, +} from "./adapter-coexistence-v1.mjs"; + +function expectFailure(action, fragment) { + try { + action(); + } catch (error) { + if (error.message.includes(fragment)) return; + throw error; + } + throw new Error(`expected failure containing ${JSON.stringify(fragment)}`); +} + +const source = '
'; +const inventory = extractStaticClassGroups(source, "contract-test"); +if ( + JSON.stringify(inventory.groups) !== JSON.stringify(["flex gap-4", "px-4 py-2"]) || + !decorateClassNodes(source).includes('data-g6-node="g6-node-001"') +) { + throw new Error("static class extraction or decoration drifted"); +} +const manifest = { + styles: [ + { className: "pc_one", origins: [{ source: "flex gap-4" }] }, + { className: "pc_two", origins: [{ source: "px-4 py-2" }] }, + ], +}; +const rewritten = rewriteStaticClassGroups(source, manifest, "contract-test"); +if ( + rewritten.replacements !== 2 || + rewritten.document !== '
' +) { + throw new Error("static manifest-authorized rewrite drifted"); +} + +for (const dynamic of [ + '
', + "
", + '
', + '
', + '
', + '
', + '
', + '
', + '', + '', +]) { + expectFailure(() => extractStaticClassGroups(dynamic, "dynamic-test"), "unsupported or dynamic"); +} +expectFailure( + () => rewriteStaticClassGroups(source, { styles: manifest.styles.slice(0, 1) }, "missing-test"), + "manifest has no generated class", +); +expectFailure( + () => + rewriteStaticClassGroups( + source, + { + styles: [ + ...manifest.styles, + { className: "pc_other", origins: [{ source: "flex gap-4" }] }, + ], + }, + "ambiguous-test", + ), + "multiple generated classes", +); + +process.stdout.write("adapter coexistence static protocol contract: pass\n"); diff --git a/scripts/benchmark-authority-v2.mjs b/scripts/benchmark-authority-v2.mjs new file mode 100644 index 0000000..7308ddb --- /dev/null +++ b/scripts/benchmark-authority-v2.mjs @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +export const authorityPath = join( + repositoryRoot, + "benchmarks", + "benchmark-authority-v2", + "oracle.json", +); + +export function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function loadAuthority() { + return JSON.parse(readFileSync(authorityPath, "utf8")); +} + +export function extractClassValues(html) { + return [...html.matchAll(/class="([^"]*)"/gu)].map((match) => match[1]); +} + +export function materializeCorpus(corpus) { + const source = readFileSync(resolve(repositoryRoot, corpus.source), "utf8").replaceAll( + "\r\n", + "\n", + ); + if (corpus.materialization === "tracked") { + return source; + } + if (corpus.materialization !== "repeat-body") { + throw new Error(`unknown corpus materialization ${JSON.stringify(corpus.materialization)}`); + } + const body = /]*>([\s\S]*?)<\/body>/u.exec(source); + if (!body) { + throw new Error(`${corpus.source} has no materializable body`); + } + const bodyClass = body[1]?.trim() ?? ""; + const contents = body[2].trim(); + const copies = Array.from({ length: corpus.repeat }, (_, index) => { + const classAttribute = bodyClass ? ` class="${bodyClass}"` : ""; + return `
\n${contents}\n
`; + }).join("\n"); + return [ + "", + '', + " ", + ' ', + ' ', + ` PliegoCSS benchmark authority ${corpus.id} corpus`, + " ", + " ", + copies, + " ", + "", + "", + ].join("\n"); +} + +export function corpusMetrics(html) { + const classValues = extractClassValues(html); + const utilityTokens = classValues.flatMap((value) => value.trim().split(/\s+/u).filter(Boolean)); + return { + sha256: sha256(Buffer.from(html, "utf8")), + htmlBytes: Buffer.byteLength(html), + classAttributeCount: classValues.length, + utilityOccurrences: utilityTokens.length, + uniqueUtilityTokens: new Set(utilityTokens).size, + uniqueStyleInputs: new Set(classValues).size, + }; +} + +export function round(value) { + return Number(value.toFixed(3)); +} + +function median(sorted) { + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +export function summarize(values, unit) { + if (!Array.isArray(values) || values.length === 0 || values.some((value) => !Number.isFinite(value))) { + throw new Error("cannot summarize an empty or non-finite sample series"); + } + const sorted = [...values].sort((left, right) => left - right); + const center = median(sorted); + const deviations = sorted.map((value) => Math.abs(value - center)).sort((left, right) => left - right); + return { + unit, + samples: values.map(round), + count: values.length, + min: round(sorted[0]), + median: round(center), + p95: round(sorted[Math.ceil(sorted.length * 0.95) - 1]), + max: round(sorted.at(-1)), + medianAbsoluteDeviation: round(median(deviations)), + }; +} + +export function packageJsonPath(alias) { + return join(repositoryRoot, "node_modules", ...alias.split("/"), "package.json"); +} + +export function readInstalledPackage(alias) { + const path = packageJsonPath(alias); + return { path, value: JSON.parse(readFileSync(path, "utf8")) }; +} + +export function resolveLaneCli(lane) { + const installed = readInstalledPackage(lane.cliPackageAlias); + const bin = installed.value.bin; + const relative = typeof bin === "string" ? bin : (bin?.tailwindcss ?? bin?.tailwind); + if (!relative) { + throw new Error(`${lane.cliPackageAlias} exposes no Tailwind CLI binary`); + } + return resolve(dirname(installed.path), relative); +} + +export function canonicalLaneMap(authority) { + return new Map(authority.lanes.map((lane) => [lane.id, lane])); +} diff --git a/scripts/benchmark-process-metrics.rs b/scripts/benchmark-process-metrics.rs new file mode 100644 index 0000000..1a613c2 --- /dev/null +++ b/scripts/benchmark-process-metrics.rs @@ -0,0 +1,171 @@ +use std::env; +use std::process::{Command, ExitCode, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(windows)] +use std::ffi::c_void; +#[cfg(target_os = "macos")] +use std::ffi::c_void; +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(windows)] +use std::mem::{size_of, zeroed}; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; + +#[cfg(windows)] +#[allow(non_snake_case)] +#[repr(C)] +struct ProcessMemoryCounters { + cb: u32, + PageFaultCount: u32, + PeakWorkingSetSize: usize, + WorkingSetSize: usize, + QuotaPeakPagedPoolUsage: usize, + QuotaPagedPoolUsage: usize, + QuotaPeakNonPagedPoolUsage: usize, + QuotaNonPagedPoolUsage: usize, + PagefileUsage: usize, + PeakPagefileUsage: usize, +} + +#[cfg(windows)] +#[link(name = "psapi")] +unsafe extern "system" { + fn GetProcessMemoryInfo( + process: *mut c_void, + counters: *mut ProcessMemoryCounters, + size: u32, + ) -> i32; +} + +#[cfg(windows)] +fn peak_working_set(child: &std::process::Child) -> Option { + let mut counters: ProcessMemoryCounters = unsafe { zeroed() }; + counters.cb = size_of::() as u32; + let succeeded = unsafe { + GetProcessMemoryInfo( + child.as_raw_handle(), + &mut counters, + size_of::() as u32, + ) + }; + (succeeded != 0).then_some(counters.PeakWorkingSetSize as u64) +} + +#[cfg(target_os = "linux")] +fn peak_working_set(child: &std::process::Child) -> Option { + let status = fs::read_to_string(format!("/proc/{}/status", child.id())).ok()?; + let kilobytes = status.lines().find_map(|line| { + line.strip_prefix("VmHWM:")? + .split_whitespace() + .next()? + .parse::() + .ok() + })?; + Some(kilobytes * 1024) +} + +#[cfg(target_os = "macos")] +#[repr(C)] +struct RusageInfoV2 { + uuid: [u8; 16], + user_time: u64, + system_time: u64, + pkg_idle_wkups: u64, + interrupt_wkups: u64, + pageins: u64, + wired_size: u64, + resident_size: u64, + phys_footprint: u64, + proc_start_abstime: u64, + proc_exit_abstime: u64, +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32; +} + +#[cfg(target_os = "macos")] +fn peak_working_set(child: &std::process::Child) -> Option { + const RUSAGE_INFO_V2: i32 = 2; + let mut usage = RusageInfoV2 { + uuid: [0; 16], + user_time: 0, + system_time: 0, + pkg_idle_wkups: 0, + interrupt_wkups: 0, + pageins: 0, + wired_size: 0, + resident_size: 0, + phys_footprint: 0, + proc_start_abstime: 0, + proc_exit_abstime: 0, + }; + let succeeded = unsafe { + proc_pid_rusage( + child.id() as i32, + RUSAGE_INFO_V2, + (&mut usage as *mut RusageInfoV2).cast::(), + ) + }; + (succeeded == 0).then_some(usage.resident_size) +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +fn peak_working_set(_child: &std::process::Child) -> Option { + None +} + +fn main() -> ExitCode { + let mut arguments = env::args_os(); + let _program = arguments.next(); + let Some(command) = arguments.next() else { + eprintln!("usage: benchmark-process-metrics [arguments ...]"); + return ExitCode::from(2); + }; + + let started = Instant::now(); + let mut child = match Command::new(command) + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(error) => { + eprintln!("cannot start measured process: {error}"); + return ExitCode::from(2); + } + }; + + let mut peak = 0_u64; + loop { + if let Some(observed) = peak_working_set(&child) { + peak = peak.max(observed); + } + match child.try_wait() { + Ok(Some(status)) => { + let elapsed = started.elapsed().as_nanos(); + let code = status.code().unwrap_or(-1); + println!( + "{{\"schemaVersion\":1,\"elapsedNs\":{elapsed},\"peakWorkingSetBytes\":{peak},\"exitCode\":{code}}}" + ); + return if status.success() { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + }; + } + Ok(None) => thread::sleep(Duration::from_millis(1)), + Err(error) => { + eprintln!("cannot observe measured process: {error}"); + let _ = child.kill(); + return ExitCode::from(2); + } + } + } +} diff --git a/scripts/browser-output-certification.mjs b/scripts/browser-output-certification.mjs new file mode 100644 index 0000000..2309a75 --- /dev/null +++ b/scripts/browser-output-certification.mjs @@ -0,0 +1,721 @@ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; +import { chromium, firefox, webkit } from "playwright"; +import { + authorityPath as competitorAuthorityPath, + extractClassValues, + loadAuthority as loadCompetitorAuthority, + readInstalledPackage, + repositoryRoot, + resolveLaneCli, + sha256, +} from "./benchmark-authority-v2.mjs"; +import { targetDirectoryForRustcIdentity } from "./rust-target.mjs"; + +const authorityPath = resolve( + repositoryRoot, + "benchmarks", + "browser-output-certification-v1", + "authority.json", +); +const authority = JSON.parse(readFileSync(authorityPath, "utf8")); +const browserTypes = { chromium, firefox, webkit }; + +function fail(message) { + throw new Error(`Browser/output certification: ${message}`); +} + +function parseOptions(arguments_) { + const options = { + browser: null, + host: null, + output: null, + requireClean: false, + }; + for (const argument of arguments_) { + if (argument === "--") continue; + if (argument === "--require-clean") options.requireClean = true; + else if (argument.startsWith("--browser=")) options.browser = argument.slice(10); + else if (argument.startsWith("--host=")) options.host = argument.slice(7); + else if (argument.startsWith("--output=")) { + options.output = resolve(repositoryRoot, argument.slice(9)); + } else { + fail( + "usage: node scripts/browser-output-certification.mjs --browser= [--host=] [--output=] [--require-clean]", + ); + } + } + if (!authority.browsers.includes(options.browser)) fail("a supported --browser is required"); + const candidates = authority.hosts.filter( + (host) => + host.browser === options.browser && + host.os === process.platform && + host.arch === process.arch, + ); + const host = options.host + ? authority.hosts.find((entry) => entry.id === options.host) + : candidates[0]; + if (!host) fail("the current OS/architecture/browser has no authority host"); + if ( + host.browser !== options.browser || + host.os !== process.platform || + host.arch !== process.arch + ) { + fail( + `host ${host.id} requires ${host.os}/${host.arch}/${host.browser}, found ${process.platform}/${process.arch}/${options.browser}`, + ); + } + options.host = host; + options.output ??= resolve( + repositoryRoot, + "target", + "browser-output-certification", + `${host.id}.local.json`, + ); + const child = relative(repositoryRoot, options.output); + if ( + !child || + child === ".." || + child.startsWith(`..${sep}`) || + isAbsolute(child) || + !options.output.endsWith(".json") + ) { + fail("--output must be a .json path inside the repository"); + } + return options; +} + +function run(command, arguments_, options = {}) { + const result = spawnSync(command, arguments_, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + timeout: 600_000, + maxBuffer: 32 * 1024 * 1024, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail( + `${command} ${arguments_.join(" ")} exited ${result.status}\n${`${result.stdout ?? ""}${result.stderr ?? ""}`.slice(-8_192)}`, + ); + } + return result; +} + +function git(arguments_) { + return run("git", arguments_).stdout; +} + +function gitState() { + const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]); + return { + commit: git(["rev-parse", "HEAD"]).trim(), + gitTree: git(["rev-parse", "HEAD^{tree}"]).trim(), + dirty: status.length > 0, + statusEntryCount: status.split("\0").filter(Boolean).length, + statusSha256: sha256(Buffer.from(status, "utf8")), + }; +} + +function instant(date = new Date()) { + return date.toISOString().replace(/\.\d{3}Z$/u, "Z"); +} + +function prepareCompiler() { + const rustcVerbose = run("rustc", ["+1.96.0", "-vV"]).stdout.trim(); + const host = /^host: (\S+)$/mu.exec(rustcVerbose)?.[1]; + if (!host) fail("cannot resolve Rust host"); + const cargoTarget = targetDirectoryForRustcIdentity( + repositoryRoot, + process.env, + rustcVerbose, + ); + const environment = { + ...process.env, + CARGO_INCREMENTAL: "0", + CARGO_TARGET_DIR: cargoTarget, + CARGO_TERM_COLOR: "never", + }; + run( + "cargo", + ["+1.96.0", "build", "--release", "--locked", "--target", host, "-p", "pliego-cssc"], + { env: environment }, + ); + const executable = join( + cargoTarget, + host, + "release", + process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", + ); + return { + executable, + sha256: sha256(readFileSync(executable)), + rustc: /^rustc .+$/mu.exec(rustcVerbose)?.[0] ?? "unknown", + host, + }; +} + +function tailwindInput(packageAlias) { + return `@layer theme, utilities;\n@import "${packageAlias}/theme.css" layer(theme);\n@import "${packageAlias}/utilities.css" layer(utilities) source(none);\n@source "./fixture.html";\n@theme {\n --color-canvas: oklch(98.5% .004 80);\n --color-surface: #fff;\n --color-surface-raised: oklch(96% .006 80);\n --color-ink: oklch(19% .012 70);\n --color-muted: oklch(50% .014 70);\n --color-accent: oklch(58% .19 32);\n --color-accent-strong: oklch(50% .18 30);\n --color-line: oklch(88% .008 75);\n --font-sans: ui-sans-serif, system-ui, sans-serif;\n --shadow-sm: 0 1px 2px #0000000d;\n}\n`; +} + +function decorateFixture(html) { + let index = 0; + return html.replace(/class="([^"]*)"/gu, (_, classes) => { + const id = `node-${String(index).padStart(2, "0")}`; + index += 1; + return `data-cert-node="${id}" class="${classes}"`; + }); +} + +function rewritePliegoHtml(html, manifest) { + const bySource = new Map(); + for (const style of manifest.styles ?? []) { + for (const origin of style.origins ?? []) { + const existing = bySource.get(origin.source); + if (existing && existing !== style.className) { + fail(`manifest maps ${JSON.stringify(origin.source)} to multiple classes`); + } + bySource.set(origin.source, style.className); + } + } + let replacements = 0; + const rewritten = html.replace(/class="([^"]*)"/gu, (_, source) => { + const replacement = bySource.get(source); + if (!replacement) fail(`manifest has no generated class for ${JSON.stringify(source)}`); + replacements += 1; + return `class="${replacement}"`; + }); + if (replacements !== extractClassValues(html).length) fail("not every class attribute was rewritten"); + return rewritten; +} + +function withoutStylesheetLink(html) { + return html.replace(/\s*]*>\s*/u, "\n"); +} + +function browserDocument(html, css, resetCss) { + const deterministicCss = `\n*,:before,:after{animation-duration:0s!important;animation-delay:0s!important;transition-duration:0s!important;caret-color:transparent!important}\n`; + const style = ``; + return withoutStylesheetLink(html).replace("", `${style}`); +} + +function prepareOutputs(compiler) { + const competitor = loadCompetitorAuthority(); + const lane = competitor.lanes.find((entry) => entry.id === authority.competitorLane); + if (!lane) fail(`competitor lane ${authority.competitorLane} is missing`); + if (Date.now() > Date.parse(competitor.expiresAtUtc)) fail("competitor oracle is expired"); + const fixturePath = resolve(repositoryRoot, authority.fixture.path); + const fixtureBytes = readFileSync(fixturePath); + if (sha256(fixtureBytes) !== authority.fixture.sha256) fail("fixture hash drifted"); + const resetPath = resolve(repositoryRoot, authority.reset.path); + const resetBytes = readFileSync(resetPath); + if (sha256(resetBytes) !== authority.reset.sha256) fail("shared reset hash drifted"); + + const workRoot = resolve( + repositoryRoot, + "target", + "browser-output-certification", + `work-${process.pid}-${Date.now()}`, + ); + mkdirSync(workRoot, { recursive: true }); + const fixture = decorateFixture(fixtureBytes.toString("utf8")); + const values = [...new Set(extractClassValues(fixture))]; + const fixtureOutput = join(workRoot, "fixture.html"); + const stylesOutput = join(workRoot, "styles.txt"); + const inputOutput = join(workRoot, "input.css"); + const tailwindOutput = join(workRoot, "tailwind.css"); + const pliegoOutput = join(workRoot, "pliego.css"); + const manifestOutput = join(workRoot, "pliego.manifest.json"); + writeFileSync(fixtureOutput, fixture); + writeFileSync(stylesOutput, `${values.join("\n")}\n`); + writeFileSync(inputOutput, tailwindInput(lane.packageAlias)); + run( + process.execPath, + [resolveLaneCli(lane), "-i", inputOutput, "-o", tailwindOutput, "--minify"], + { cwd: workRoot }, + ); + run( + compiler.executable, + [ + "compile", + "--input", + stylesOutput, + "--seed", + "--theme", + "--output", + pliegoOutput, + "--manifest", + manifestOutput, + ], + { cwd: workRoot }, + ); + const manifest = JSON.parse(readFileSync(manifestOutput, "utf8")); + const pliegoHtml = rewritePliegoHtml(fixture, manifest); + const tailwindCss = readFileSync(tailwindOutput, "utf8"); + const pliegoCss = readFileSync(pliegoOutput, "utf8"); + return { + workRoot, + lane, + resetCss: resetBytes.toString("utf8"), + html: { pliego: pliegoHtml, tailwind: fixture }, + css: { pliego: pliegoCss, tailwind: tailwindCss }, + provenance: { + competitorAuthoritySha256: sha256(readFileSync(competitorAuthorityPath)), + competitorObservedAtUtc: competitor.observedAtUtc, + competitorExpiresAtUtc: competitor.expiresAtUtc, + tailwindVersion: lane.version, + tailwindCliVersion: lane.cliVersion, + tailwindPackageManifestSha256: sha256( + readFileSync(readInstalledPackage(lane.packageAlias).path), + ), + tailwindCliManifestSha256: sha256( + readFileSync(readInstalledPackage(lane.cliPackageAlias).path), + ), + fixtureSha256: authority.fixture.sha256, + resetSha256: authority.reset.sha256, + pliegoCssSha256: sha256(pliegoCss), + tailwindCssSha256: sha256(tailwindCss), + }, + }; +} + +async function capture(page, properties) { + return page.evaluate(({ propertyNames, lengthQuantum, geometryResolution }) => { + const colorProperties = new Set([ + "color", + "background-color", + "border-top-color", + "outline-color", + ]); + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + const normalizeColor = (value) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = "rgba(0,0,0,0)"; + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data].join(","); + }; + const normalizeLengths = (value) => + value.replace(/-?(?:\d+\.?\d*|\.\d+)px/gu, (token) => { + const number = Number.parseFloat(token); + const rounded = Math.round(number / lengthQuantum) * lengthQuantum; + return `${Object.is(rounded, -0) ? 0 : Number(rounded.toFixed(4))}px`; + }); + const quantize = (value) => { + const rounded = Math.round(value / geometryResolution) * geometryResolution; + return Object.is(rounded, -0) ? 0 : Number(rounded.toFixed(6)); + }; + const rectangle = (value) => ({ + x: quantize(value.x), + y: quantize(value.y), + width: quantize(value.width), + height: quantize(value.height), + }); + const directTextRectangles = (element) => { + const rectangles = []; + for (const node of element.childNodes) { + if (node.nodeType !== Node.TEXT_NODE || !node.textContent?.trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + rectangles.push(...[...range.getClientRects()].map(rectangle)); + range.detach(); + } + return rectangles; + }; + const nodes = {}; + for (const element of document.querySelectorAll("[data-cert-node]")) { + const id = element.getAttribute("data-cert-node"); + const style = getComputedStyle(element); + const computed = {}; + for (const property of propertyNames) { + let value = style.getPropertyValue(property).trim(); + if (colorProperties.has(property)) value = normalizeColor(value); + if (property === "justify-content" && value === "end") value = "flex-end"; + value = normalizeLengths(value); + computed[property] = value; + } + const record = { + tag: element.tagName.toLowerCase(), + computed, + geometry: { + borderBox: rectangle(element.getBoundingClientRect()), + clientWidth: element.clientWidth, + clientHeight: element.clientHeight, + scrollWidth: element.scrollWidth, + scrollHeight: element.scrollHeight, + directTextRects: directTextRectangles(element), + }, + }; + if (element.matches("input[placeholder], textarea[placeholder]")) { + const placeholder = getComputedStyle(element, "::placeholder"); + record.placeholder = { + color: normalizeColor(placeholder.getPropertyValue("color").trim()), + opacity: placeholder.getPropertyValue("opacity").trim(), + }; + } + nodes[id] = record; + } + return { + activeNode: document.activeElement?.getAttribute("data-cert-node") ?? null, + documentElement: { + backgroundColor: getComputedStyle(document.documentElement).backgroundColor, + colorScheme: getComputedStyle(document.documentElement).colorScheme, + }, + nodes, + }; + }, { + propertyNames: properties, + lengthQuantum: authority.comparison.computedLengthQuantumCssPx, + geometryResolution: authority.comparison.layoutGeometryResolutionCssPx, + }); +} + +function compareScreenshots(pliegoBytes, tailwindBytes, diffPath) { + const pliego = PNG.sync.read(pliegoBytes); + const tailwind = PNG.sync.read(tailwindBytes); + if (pliego.width !== tailwind.width || pliego.height !== tailwind.height) { + return { + passed: false, + byteEqual: false, + width: pliego.width, + height: pliego.height, + tailwindWidth: tailwind.width, + tailwindHeight: tailwind.height, + mismatchPixels: null, + mismatchRatio: null, + diff: null, + }; + } + const diff = new PNG({ width: pliego.width, height: pliego.height }); + const mismatchPixels = pixelmatch( + pliego.data, + tailwind.data, + diff.data, + pliego.width, + pliego.height, + { + threshold: authority.comparison.screenshotPixelThreshold, + includeAA: false, + diffMask: true, + }, + ); + const mismatchRatio = mismatchPixels / (pliego.width * pliego.height); + const diffBytes = PNG.sync.write(diff); + writeFileSync(diffPath, diffBytes); + return { + passed: mismatchRatio <= authority.comparison.maximumScreenshotMismatchRatio, + byteEqual: pliegoBytes.equals(tailwindBytes), + width: pliego.width, + height: pliego.height, + tailwindWidth: tailwind.width, + tailwindHeight: tailwind.height, + mismatchPixels, + mismatchRatio: Number(mismatchRatio.toFixed(9)), + diff: { + path: relative(dirname(options.output), diffPath).replaceAll("\\", "/"), + sha256: sha256(diffBytes), + bytes: diffBytes.byteLength, + }, + }; +} + +function computedDiff(pliego, tailwind) { + const differences = []; + const geometry = { maximumDeltaCssPx: 0 }; + const ids = [...new Set([...Object.keys(pliego.nodes), ...Object.keys(tailwind.nodes)])].sort(); + if (pliego.activeNode !== tailwind.activeNode) { + differences.push({ path: "activeNode", pliego: pliego.activeNode, tailwind: tailwind.activeNode }); + } + for (const id of ids) { + const left = pliego.nodes[id]; + const right = tailwind.nodes[id]; + if (!left || !right) { + differences.push({ path: id, pliego: left ?? null, tailwind: right ?? null }); + continue; + } + for (const property of authority.computedProperties) { + if (left.computed[property] !== right.computed[property]) { + differences.push({ + path: `${id}.${property}`, + pliego: left.computed[property], + tailwind: right.computed[property], + }); + } + } + geometryDiff(left.geometry, right.geometry, `${id}.geometry`, differences, geometry); + if (JSON.stringify(left.placeholder ?? null) !== JSON.stringify(right.placeholder ?? null)) { + differences.push({ + path: `${id}.placeholder`, + pliego: left.placeholder ?? null, + tailwind: right.placeholder ?? null, + }); + } + } + return { + differences, + maximumLayoutGeometryDeltaCssPx: Number(geometry.maximumDeltaCssPx.toFixed(6)), + }; +} + +function geometryDiff(pliego, tailwind, path, differences, geometry) { + if (typeof pliego === "number" && typeof tailwind === "number") { + const delta = Math.abs(pliego - tailwind); + geometry.maximumDeltaCssPx = Math.max(geometry.maximumDeltaCssPx, delta); + if (delta > authority.comparison.maximumLayoutGeometryDeltaCssPx) { + differences.push({ path, pliego, tailwind, delta: Number(delta.toFixed(6)) }); + } + return; + } + if (Array.isArray(pliego) && Array.isArray(tailwind)) { + if (pliego.length !== tailwind.length) { + differences.push({ path: `${path}.length`, pliego: pliego.length, tailwind: tailwind.length }); + return; + } + for (let index = 0; index < pliego.length; index += 1) { + geometryDiff(pliego[index], tailwind[index], `${path}[${index}]`, differences, geometry); + } + return; + } + if ( + pliego && + tailwind && + typeof pliego === "object" && + typeof tailwind === "object" + ) { + const keys = [...new Set([...Object.keys(pliego), ...Object.keys(tailwind)])].sort(); + for (const key of keys) { + geometryDiff(pliego[key], tailwind[key], `${path}.${key}`, differences, geometry); + } + return; + } + if (pliego !== tailwind) differences.push({ path, pliego: pliego ?? null, tailwind: tailwind ?? null }); +} + +async function renderEngine(browserInstance, engine, prepared, mode, scenario, screenshotPath) { + const context = await browserInstance.newContext({ + viewport: { width: scenario.width, height: scenario.height }, + deviceScaleFactor: 1, + locale: "en-US", + colorScheme: "light", + reducedMotion: "reduce", + forcedColors: "none", + }); + const page = await context.newPage(); + try { + const resetCss = mode.id === "shared-reset" ? prepared.resetCss : ""; + await page.setContent( + browserDocument(prepared.html[engine], prepared.css[engine], resetCss), + { waitUntil: "load" }, + ); + await page.evaluate(() => document.fonts.ready); + if (scenario.action === "hover") await page.locator(scenario.selector).hover(); + else if (scenario.action === "focus") await page.locator(scenario.selector).focus(); + else if (scenario.action !== "none") fail(`unknown scenario action ${scenario.action}`); + const computed = await capture(page, authority.computedProperties); + const screenshot = await page.screenshot({ + path: screenshotPath, + fullPage: true, + animations: "disabled", + caret: "hide", + scale: "css", + }); + return { computed, screenshot }; + } finally { + await context.close(); + } +} + +const options = parseOptions(process.argv.slice(2)); +const state = gitState(); +if (options.requireClean && state.dirty) fail("--require-clean rejects a dirty Git worktree"); +const generatedAt = new Date(); +const expiresAt = new Date( + generatedAt.getTime() + authority.maximumEvidenceAgeHours * 60 * 60 * 1_000, +); +const compiler = prepareCompiler(); +const prepared = prepareOutputs(compiler); +const outputDirectory = dirname(options.output); +mkdirSync(outputDirectory, { recursive: true }); +const screenshotRoot = resolve( + outputDirectory, + `${basename(options.output, ".json")}.screenshots`, +); +const screenshotChild = relative(outputDirectory, screenshotRoot); +if (!screenshotChild || screenshotChild.startsWith("..") || isAbsolute(screenshotChild)) { + fail("unsafe screenshot output path"); +} +rmSync(screenshotRoot, { recursive: true, force: true }); +mkdirSync(screenshotRoot, { recursive: true }); + +const playwrightPackage = JSON.parse( + readFileSync(resolve(repositoryRoot, "node_modules", "playwright", "package.json"), "utf8"), +); +const browserInstance = await browserTypes[options.browser].launch({ headless: true }); +const observations = []; +let computedMismatchCount = 0; +let screenshotMismatchCount = 0; +let maximumLayoutGeometryDeltaCssPx = 0; +try { + for (const mode of authority.resetModes) { + for (const scenario of authority.scenarios) { + process.stdout.write(`[browser-output] ${options.host.id}/${mode.id}/${scenario.id}\n`); + const scenarioRoot = resolve(screenshotRoot, mode.id, scenario.id); + mkdirSync(scenarioRoot, { recursive: true }); + const paths = { + pliego: resolve(scenarioRoot, "pliego.png"), + tailwind: resolve(scenarioRoot, "tailwind.png"), + diff: resolve(scenarioRoot, "diff.png"), + }; + const pliego = await renderEngine( + browserInstance, + "pliego", + prepared, + mode, + scenario, + paths.pliego, + ); + const tailwind = await renderEngine( + browserInstance, + "tailwind", + prepared, + mode, + scenario, + paths.tailwind, + ); + const computedComparison = computedDiff(pliego.computed, tailwind.computed); + const { differences } = computedComparison; + const screenshotComparison = compareScreenshots( + pliego.screenshot, + tailwind.screenshot, + paths.diff, + ); + computedMismatchCount += differences.length; + maximumLayoutGeometryDeltaCssPx = Math.max( + maximumLayoutGeometryDeltaCssPx, + computedComparison.maximumLayoutGeometryDeltaCssPx, + ); + if (!screenshotComparison.passed) screenshotMismatchCount += 1; + observations.push({ + resetMode: mode.id, + scenario: scenario.id, + viewport: { width: scenario.width, height: scenario.height }, + action: scenario.action, + computed: { + equal: differences.length === 0, + differenceCount: differences.length, + differences: differences.slice(0, 100), + maximumLayoutGeometryDeltaCssPx: + computedComparison.maximumLayoutGeometryDeltaCssPx, + pliegoSha256: sha256(Buffer.from(JSON.stringify(pliego.computed))), + tailwindSha256: sha256(Buffer.from(JSON.stringify(tailwind.computed))), + nodeCount: Object.keys(pliego.computed.nodes).length, + }, + screenshots: { + ...screenshotComparison, + threshold: authority.comparison.screenshotPixelThreshold, + maximumMismatchRatio: authority.comparison.maximumScreenshotMismatchRatio, + pliego: { + path: relative(outputDirectory, paths.pliego).replaceAll("\\", "/"), + sha256: sha256(pliego.screenshot), + bytes: pliego.screenshot.byteLength, + }, + tailwind: { + path: relative(outputDirectory, paths.tailwind).replaceAll("\\", "/"), + sha256: sha256(tailwind.screenshot), + bytes: tailwind.screenshot.byteLength, + }, + }, + }); + } + } +} finally { + await browserInstance.close(); + const workChild = relative( + resolve(repositoryRoot, "target", "browser-output-certification"), + prepared.workRoot, + ); + if ( + !workChild || + workChild === ".." || + workChild.startsWith(`..${sep}`) || + isAbsolute(workChild) || + !basename(prepared.workRoot).startsWith("work-") + ) { + fail("refusing to clean an unsafe certification work directory"); + } + rmSync(prepared.workRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +} + +const result = computedMismatchCount === 0 && screenshotMismatchCount === 0 ? "passed" : "failed"; +const document = { + schemaVersion: 1, + kind: "pliegocss-browser-output-host-evidence", + result, + generatedAtUtc: instant(generatedAt), + expiresAtUtc: instant(expiresAt), + source: state, + host: { + id: options.host.id, + runner: options.host.runner, + os: process.platform, + arch: process.arch, + node: process.version, + playwright: playwrightPackage.version, + browser: options.browser, + browserVersion: browserInstance.version(), + }, + authority: { + path: relative(repositoryRoot, authorityPath).replaceAll("\\", "/"), + sha256: sha256(readFileSync(authorityPath)), + }, + compiler: { + sha256: compiler.sha256, + rustc: compiler.rustc, + host: compiler.host, + }, + inputs: prepared.provenance, + resetModes: authority.resetModes, + computedProperties: authority.computedProperties, + observations, + coverage: authority.requiredCoverage, + summary: { + resetModeCount: authority.resetModes.length, + scenarioCount: observations.length, + computedMismatchCount, + screenshotMismatchCount, + maximumLayoutGeometryDeltaCssPx: Number(maximumLayoutGeometryDeltaCssPx.toFixed(6)), + }, + claimBoundary: authority.comparison.claimBoundary, +}; +writeFileSync(options.output, `${JSON.stringify(document, null, 2)}\n`); +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + result, + host: options.host.id, + browserVersion: document.host.browserVersion, + scenarios: observations.length, + computedMismatchCount, + screenshotMismatchCount, + maximumLayoutGeometryDeltaCssPx: document.summary.maximumLayoutGeometryDeltaCssPx, + output: relative(repositoryRoot, options.output).replaceAll("\\", "/"), + }, + null, + 2, + )}\n`, +); +if (result !== "passed") process.exitCode = 1; diff --git a/scripts/build-repository-distribution.mjs b/scripts/build-repository-distribution.mjs new file mode 100644 index 0000000..ab68ba1 --- /dev/null +++ b/scripts/build-repository-distribution.mjs @@ -0,0 +1,314 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, resolve } from "node:path"; +import { + ROOT, + assertChildPath, + digest, + exactKeys, + fail, + fileSha256, + readJson, + repositoryVersion, + safeFilename, + validateBinary, + validateCycloneDx, + validatePackageManifest, + validatePolicy, + validateSource, +} from "./repository-distribution.mjs"; + +function parse(args) { + const value = { allowDirty: false }; + for (const arg of args) { + if (arg === "--") continue; + if (arg === "--allow-dirty") { + value.allowDirty = true; + continue; + } + const match = /^--([a-z-]+)=(.+)$/u.exec(arg); + if (!match || Object.hasOwn(value, match[1])) fail(`invalid option: ${arg}`); + value[match[1]] = match[2]; + } + return value; +} + +function run(command, args, { cwd = ROOT, capture = false } = {}) { + const executable = + process.platform === "win32" && command === "pnpm" + ? process.env.ComSpec ?? "cmd.exe" + : command; + const commandArgs = + process.platform === "win32" && command === "pnpm" + ? ["/d", "/s", "/c", command, ...args] + : args; + const result = spawnSync(executable, commandArgs, { + cwd, + encoding: "utf8", + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + windowsHide: true, + }); + if (result.error || result.status !== 0) { + const details = capture ? `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() : ""; + fail( + `${command} ${args.join(" ")} failed: ${result.error?.message ?? (details || result.status)}`, + ); + } + return result.stdout?.trim() ?? ""; +} + +function safeOutput(path) { + return assertChildPath(resolve(ROOT, "target"), path, "distribution output"); +} + +function artifact(path, kind, extra = {}) { + return { + kind, + file: basename(path), + bytes: statSync(path).size, + sha256: fileSha256(path), + ...extra, + }; +} + +const options = parse(process.argv.slice(2)); +if (!options.input || !options.output) fail("--input and --output are required"); +const inputRoot = resolve(ROOT, options.input); +const outputRoot = safeOutput(resolve(ROOT, options.output)); +const dirty = run("git", ["status", "--porcelain=v1"], { capture: true }); +if (dirty && !options.allowDirty) fail("distribution build requires a clean worktree"); +const version = repositoryVersion(); +const source = { + commit: options["source-commit"] ?? run("git", ["rev-parse", "HEAD"], { capture: true }), + gitTree: + options["git-tree"] ?? + run("git", ["show", "-s", "--format=%T", "HEAD"], { capture: true }), + tag: options.tag ?? null, +}; +validateSource(source); +if (source.tag !== null && source.tag !== `v${version}`) { + fail(`source tag ${source.tag} does not match package version ${version}`); +} +const policy = validatePolicy(); +validatePackageManifest(undefined, policy); + +rmSync(outputRoot, { recursive: true, force: true }); +const releaseRoot = resolve(outputRoot, "release-assets"); +const stageRoot = resolve(outputRoot, "stage"); +mkdirSync(releaseRoot, { recursive: true }); +mkdirSync(stageRoot, { recursive: true }); + +const nativeInputs = []; +for (const target of policy.targets) { + const root = resolve(inputRoot, `native-${target.id}`); + const receiptPath = resolve(root, "native.json"); + if (!existsSync(receiptPath)) fail(`native receipt missing for ${target.id}`); + const receipt = readJson(receiptPath, `${target.id} native receipt`); + exactKeys( + receipt, + ["schemaVersion", "kind", "version", "source", "target", "binaries", "sboms"], + `${target.id} native receipt`, + ); + if ( + receipt.schemaVersion !== 1 || + receipt.kind !== "pliegocss-native-distribution-input" || + receipt.version !== version || + receipt.target.id !== target.id + ) { + fail(`${target.id} native receipt identity drifted`); + } + exactKeys(receipt.target, ["id", "os", "arch", "format"], `${target.id} receipt target`); + const expectedTarget = { + id: target.id, + os: target.os, + arch: target.arch, + format: target.format, + }; + if (JSON.stringify(receipt.target) !== JSON.stringify(expectedTarget)) { + fail(`${target.id} native receipt target drifted`); + } + validateSource(receipt.source); + if (JSON.stringify(receipt.source) !== JSON.stringify(source)) { + fail(`${target.id} native input is not bound to the aggregate source`); + } + if (!Array.isArray(receipt.binaries) || receipt.binaries.length !== policy.binaries.length) { + fail(`${target.id} native receipt binary inventory drifted`); + } + for (const [index, binary] of receipt.binaries.entries()) { + exactKeys(binary, ["name", "file", "bytes", "sha256"], `${target.id} binary receipt`); + const expectedName = policy.binaries[index]; + const expectedFile = `${expectedName}${target.extension}`; + if ( + binary.name !== expectedName || + binary.file !== expectedFile || + !Number.isSafeInteger(binary.bytes) || + binary.bytes <= 0 + ) { + fail(`${target.id} binary receipt inventory drifted`); + } + safeFilename(binary.file, `${target.id} binary file`); + digest(binary.sha256, `${target.id}/${binary.file}.sha256`); + const path = resolve(root, binary.file); + const details = validateBinary(path, target); + if (details.sha256 !== binary.sha256 || details.bytes !== binary.bytes) { + fail(`${target.id}/${binary.file} drifted after native staging`); + } + } + if (!Array.isArray(receipt.sboms) || receipt.sboms.length !== policy.binaries.length) { + fail(`${target.id} native receipt SBOM inventory drifted`); + } + for (const [index, sbom] of receipt.sboms.entries()) { + exactKeys(sbom, ["binary", "file", "sha256"], `${target.id} SBOM receipt`); + const expectedBinary = policy.binaries[index]; + if ( + sbom.binary !== expectedBinary || + sbom.file !== `${expectedBinary}.cdx.json` + ) { + fail(`${target.id} SBOM receipt inventory drifted`); + } + safeFilename(sbom.file, `${target.id} SBOM file`); + digest(sbom.sha256, `${target.id}/${sbom.file}.sha256`); + const path = resolve(root, sbom.file); + validateCycloneDx(path, sbom.binary, version); + if (fileSha256(path) !== sbom.sha256) { + fail(`${target.id}/${sbom.file} drifted after native staging`); + } + } + nativeInputs.push({ target, root, receipt }); +} + +const packageStage = resolve(stageRoot, "pnpm-package"); +mkdirSync(resolve(packageStage, "bin"), { recursive: true }); +mkdirSync(resolve(packageStage, "vendor"), { recursive: true }); +mkdirSync(resolve(packageStage, "SBOM"), { recursive: true }); +for (const file of ["package.json", "README.md"]) { + copyFileSync(resolve(ROOT, "packages", "cli", file), resolve(packageStage, file)); +} +copyFileSync(resolve(ROOT, "LICENSE"), resolve(packageStage, "LICENSE")); +for (const file of readdirSync(resolve(ROOT, "packages", "cli", "bin"))) { + copyFileSync( + resolve(ROOT, "packages", "cli", "bin", file), + resolve(packageStage, "bin", file), + ); +} +for (const { target, root, receipt } of nativeInputs) { + const vendor = resolve(packageStage, "vendor", target.id); + mkdirSync(vendor, { recursive: true }); + for (const binary of receipt.binaries) { + const destination = resolve(vendor, binary.file); + copyFileSync(resolve(root, binary.file), destination); + if (target.os !== "win32") chmodSync(destination, 0o755); + } + for (const sbom of receipt.sboms) { + copyFileSync( + resolve(root, sbom.file), + resolve(packageStage, "SBOM", `${target.id}-${sbom.file}`), + ); + } +} +run("pnpm", ["pack", "--pack-destination", releaseRoot], { cwd: packageStage }); +const packed = readdirSync(releaseRoot).filter((file) => file.endsWith(".tgz")); +if (packed.length !== 1) fail(`pnpm pack produced ${packed.length} tarballs`); +const pnpmAsset = resolve(releaseRoot, `pliegocss-pnpm-${version}.tgz`); +const originalPnpmAsset = resolve(releaseRoot, packed[0]); +if (originalPnpmAsset !== pnpmAsset) { + copyFileSync(originalPnpmAsset, pnpmAsset); + rmSync(originalPnpmAsset); +} + +const artifacts = []; +for (const { target, root, receipt } of nativeInputs) { + const nativeStage = resolve(stageRoot, target.id); + mkdirSync(nativeStage, { recursive: true }); + for (const file of ["LICENSE", "native.json", ...receipt.binaries.map((x) => x.file), ...receipt.sboms.map((x) => x.file)]) { + copyFileSync(resolve(root, file), resolve(nativeStage, file)); + } + for (const binary of receipt.binaries) { + if (target.os !== "win32") chmodSync(resolve(nativeStage, binary.file), 0o755); + } + const archive = resolve( + releaseRoot, + `pliegocss-${version}-${target.id}.tar.gz`, + ); + run("tar", ["-czf", archive, "-C", nativeStage, "."]); + artifacts.push( + artifact(archive, "native-archive", { + target: target.id, + binaries: receipt.binaries, + sboms: receipt.sboms, + }), + ); + for (const sbom of receipt.sboms) { + const sbomAsset = resolve(releaseRoot, `${target.id}-${sbom.file}`); + copyFileSync(resolve(root, sbom.file), sbomAsset); + artifacts.push( + artifact(sbomAsset, "sbom", { target: target.id, binary: sbom.binary }), + ); + } +} +artifacts.push( + artifact(pnpmAsset, "pnpm-package", { + target: "universal-declared-hosts", + package: policy.package.name, + lifecycleScripts: false, + dependencies: 0, + registryPublication: false, + }), +); +artifacts.sort((left, right) => left.file.localeCompare(right.file, "en")); + +const manifest = { + schemaVersion: 1, + kind: "pliegocss-repository-distribution", + version, + repository: policy.repository, + source, + packageManager: { + name: "pnpm", + version: policy.package.packageManagerVersion, + installSource: "verified-local-github-release-asset", + npmjsPublished: false, + lifecycleScripts: false, + }, + immutableReleaseRequired: true, + supportedTargets: policy.targets.map(({ id, os, arch }) => ({ id, os, arch })), + artifacts, + claimBoundary: policy.claimBoundary, +}; +const manifestPath = resolve( + releaseRoot, + `pliegocss-distribution-${version}.json`, +); +writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + +const checksumFiles = [...artifacts.map((entry) => entry.file), basename(manifestPath)].sort(); +const checksumText = `${checksumFiles + .map((file) => `${fileSha256(resolve(releaseRoot, file))} ${file}`) + .join("\n")}\n`; +writeFileSync(resolve(releaseRoot, "SHA256SUMS"), checksumText); + +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + version, + source, + releaseRoot, + artifacts: artifacts.length + 2, + pnpmPackage: basename(pnpmAsset), + worktree: dirty ? "dirty-explicitly-allowed" : "clean", + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-adapter-coexistence-authority.mjs b/scripts/check-adapter-coexistence-authority.mjs new file mode 100644 index 0000000..1e3d6c9 --- /dev/null +++ b/scripts/check-adapter-coexistence-authority.mjs @@ -0,0 +1,153 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + exactKeys, + extractStaticClassGroups, + loadContract, + profileById, + renderProjectDocument, + repositoryRoot, + sha256, +} from "./adapter-coexistence-v1.mjs"; + +function fail(message) { + throw new Error(`adapter coexistence authority: ${message}`); +} + +const { authority, corpus } = loadContract(); +exactKeys( + authority, + [ + "schemaVersion", + "status", + "kind", + "corpus", + "sharedReset", + "tailwindProfiles", + "adapters", + "hosts", + "migration", + "comparison", + "requiredCoverage", + "claimBoundary", + ], + "authority", +); +if ( + authority.schemaVersion !== 1 || + authority.status !== "active-contract" || + authority.kind !== "pliegocss-adapter-coexistence-certification" +) { + fail("header drifted"); +} +if (corpus.schemaVersion !== 1 || corpus.kind !== "pliegocss-adapter-coexistence-corpus") { + fail("corpus header drifted"); +} +if (!Array.isArray(corpus.projects) || corpus.projects.length < authority.corpus.minimumProjects) { + fail(`requires at least ${authority.corpus.minimumProjects} projects`); +} +if (authority.corpus.minimumProjects < 20) fail("minimum project floor cannot be below 20"); + +const ids = new Set(); +const documentHashes = new Set(); +const adapterCounts = Object.fromEntries(authority.adapters.map((adapter) => [adapter.id, 0])); +const profileCounts = Object.fromEntries(authority.tailwindProfiles.map((profile) => [profile.id, 0])); +let classGroups = 0; +for (const project of corpus.projects) { + exactKeys( + project, + ["id", "adapter", "tailwindProfile", "title", "viewport", "body"], + `project ${project.id ?? "unknown"}`, + ); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(project.id) || ids.has(project.id)) { + fail(`invalid or duplicate project id ${JSON.stringify(project.id)}`); + } + ids.add(project.id); + if (!(project.adapter in adapterCounts)) fail(`${project.id} uses an unknown adapter`); + profileById(authority, project.tailwindProfile); + if (!(project.tailwindProfile in profileCounts)) fail(`${project.id} uses an unknown profile`); + exactKeys(project.viewport, ["width", "height"], `${project.id}.viewport`); + if ( + !Number.isInteger(project.viewport.width) || + !Number.isInteger(project.viewport.height) || + project.viewport.width < 320 || + project.viewport.height < 480 + ) { + fail(`${project.id} viewport is outside the bounded browser range`); + } + const document = renderProjectDocument(project); + const groups = extractStaticClassGroups(document, project.id); + if (groups.groups.length < 3) fail(`${project.id} is not a meaningful class-bearing project`); + if (!/\b(?:aria-[a-z-]+|role)=/u.test(project.body)) { + fail(`${project.id} must expose explicit accessible semantics`); + } + if (project.adapter === "pliegors" && !project.body.includes("data-pliegors-")) { + fail(`${project.id} is missing a PliegoRS rendered-output marker`); + } + const documentHash = sha256(Buffer.from(document)); + if (documentHashes.has(documentHash)) fail(`${project.id} duplicates another project document`); + documentHashes.add(documentHash); + adapterCounts[project.adapter] += 1; + profileCounts[project.tailwindProfile] += 1; + classGroups += groups.groups.length; +} + +for (const adapter of authority.adapters) { + if (adapterCounts[adapter.id] < adapter.minimumProjects) { + fail(`${adapter.id} requires ${adapter.minimumProjects} projects, found ${adapterCounts[adapter.id]}`); + } +} +for (const profile of authority.tailwindProfiles) { + if (profileCounts[profile.id] < 7) fail(`${profile.id} requires at least seven projects`); + const installed = JSON.parse( + readFileSync(resolve(repositoryRoot, "node_modules", profile.packageAlias, "package.json"), "utf8"), + ); + if (installed.name !== profile.packageName || installed.version !== profile.version) { + fail( + `${profile.id} expected ${profile.packageName}@${profile.version}, found ${installed.name}@${installed.version}`, + ); + } +} +const viteAdapter = authority.adapters.find((adapter) => adapter.id === "vite"); +const vite = JSON.parse(readFileSync(resolve(repositoryRoot, "node_modules", "vite", "package.json"), "utf8")); +if (vite.version !== viteAdapter.version) fail(`Vite expected ${viteAdapter.version}, found ${vite.version}`); + +const resetBytes = readFileSync(resolve(repositoryRoot, authority.sharedReset.path)); +if (sha256(resetBytes) !== authority.sharedReset.sha256) fail("shared reset hash drifted"); + +const required = new Set(authority.requiredCoverage); +for (const item of [ + "at-least-20-projects", + "html", + "vite-production-build", + "pliegors-rendered-output", + "pinned-pliegors-framework-browser", + "tailwind-v3-lts", + "tailwind-v4-current", + "tailwind-source-inventory", + "tailwind-output-audit", + "reversible-group-apply", + "exact-byte-rollback", + "dom-equivalence", + "aria-equivalence", + "computed-style-equivalence", + "layout-geometry-equivalence", +]) { + if (!required.has(item)) fail(`required coverage is missing ${item}`); +} + +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + passed: true, + projects: corpus.projects.length, + uniqueDocuments: documentHashes.size, + classGroups, + adapters: adapterCounts, + tailwindProfiles: profileCounts, + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-adapter-coexistence-matrix.mjs b/scripts/check-adapter-coexistence-matrix.mjs new file mode 100644 index 0000000..f7de1a5 --- /dev/null +++ b/scripts/check-adapter-coexistence-matrix.mjs @@ -0,0 +1,300 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + loadContract, + repositoryRoot, + sha256, +} from "./adapter-coexistence-v1.mjs"; + +function fail(message) { + throw new Error(`adapter coexistence matrix: ${message}`); +} + +function parseOptions(arguments_) { + const options = { + evidenceRoot: null, + pliegorsEvidence: null, + output: null, + requireClean: false, + }; + for (const argument of arguments_) { + if (argument === "--") continue; + if (argument === "--require-clean") { + options.requireClean = true; + } else if (argument.startsWith("--evidence-root=")) { + options.evidenceRoot = resolve(repositoryRoot, argument.slice(16)); + } else if (argument.startsWith("--pliegors-evidence=")) { + options.pliegorsEvidence = resolve(repositoryRoot, argument.slice(20)); + } else if (argument.startsWith("--output=")) { + options.output = resolve(repositoryRoot, argument.slice(9)); + } else { + fail( + "usage: node scripts/check-adapter-coexistence-matrix.mjs --evidence-root=PATH --pliegors-evidence=PATH [--output=PATH] [--require-clean]", + ); + } + } + if (!options.evidenceRoot) fail("--evidence-root is required"); + if (!options.pliegorsEvidence) fail("--pliegors-evidence is required"); + options.output ??= resolve( + repositoryRoot, + "target", + "adapter-coexistence-certification", + "adapter-coexistence-matrix.json", + ); + for (const [role, path] of Object.entries({ + "evidence root": options.evidenceRoot, + "PliegoRS evidence": options.pliegorsEvidence, + output: options.output, + })) { + const child = relative(repositoryRoot, path); + if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) { + fail(`${role} must be inside the repository`); + } + } + if (!options.output.endsWith(".json")) fail("--output must be a JSON path"); + if (!options.pliegorsEvidence.endsWith(".json")) fail("--pliegors-evidence must be a JSON path"); + return options; +} + +function filesBelow(directory) { + return readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + .flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? filesBelow(path) : [path]; + }); +} + +const options = parseOptions(process.argv.slice(2)); +if ( + options.requireClean && + execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { + cwd: repositoryRoot, + encoding: "utf8", + }).length !== 0 +) { + fail("--require-clean found a dirty matrix checkout"); +} +if (!statSync(options.evidenceRoot).isDirectory()) fail("evidence root is not a directory"); +const contract = loadContract(); +const evidence = filesBelow(options.evidenceRoot) + .filter((path) => path.endsWith(".json")) + .map((path) => ({ path, value: JSON.parse(readFileSync(path, "utf8")) })) + .filter(({ value }) => value.kind === "pliegocss-adapter-coexistence-host-evidence"); +const expectedHosts = contract.authority.hosts.map((host) => host.id).sort(); +const actualHosts = evidence.map(({ value }) => value.host?.id).sort(); +if (JSON.stringify(actualHosts) !== JSON.stringify(expectedHosts)) { + fail(`host inventory drifted: expected ${expectedHosts.join(", ")}, found ${actualHosts.join(", ")}`); +} + +const expectedProjects = contract.corpus.projects.map((project) => project.id); +const projectContract = new Map(contract.corpus.projects.map((project) => [project.id, project])); +let sourceIdentity = null; +const hosts = []; +for (const { path, value } of evidence) { + if ( + value.schemaVersion !== 1 || + value.result !== "pass" || + value.kind !== "pliegocss-adapter-coexistence-host-evidence" + ) { + fail(`${path} header or result drifted`); + } + const expectedHost = contract.authority.hosts.find((host) => host.id === value.host.id); + for (const key of ["os", "arch", "browser"]) { + if (value.host[key] !== expectedHost[key]) fail(`${value.host.id} ${key} drifted`); + } + if (!value.host.browserVersion || !value.host.playwright) { + fail(`${value.host.id} browser/tooling identity is incomplete`); + } + if ( + value.authority?.sha256 !== contract.authoritySha256 || + value.authority?.corpusSha256 !== contract.corpusSha256 + ) { + fail(`${value.host.id} authority identity drifted`); + } + if (value.source?.dirty !== false || !/^[0-9a-f]{40}$/u.test(value.source?.commit ?? "")) { + fail(`${value.host.id} source is dirty or incomplete`); + } + const identity = `${value.source.commit}:${value.source.gitTree}`; + sourceIdentity ??= identity; + if (identity !== sourceIdentity) fail(`${value.host.id} is detached from the matrix source`); + if ( + value.summary?.projects !== expectedProjects.length || + value.summary?.passed !== expectedProjects.length || + value.summary?.domEquivalent !== expectedProjects.length || + value.summary?.ariaEquivalent !== expectedProjects.length || + value.summary?.computedStyleEquivalent !== expectedProjects.length || + value.summary?.geometryEquivalent !== expectedProjects.length || + value.summary?.exactRollbacks !== expectedProjects.length || + JSON.stringify(value.summary?.adapters) !== + JSON.stringify( + Object.fromEntries( + contract.authority.adapters.map((adapter) => [ + adapter.id, + contract.corpus.projects.filter((project) => project.adapter === adapter.id).length, + ]), + ), + ) || + JSON.stringify(value.summary?.tailwindProfiles) !== + JSON.stringify( + Object.fromEntries( + contract.authority.tailwindProfiles.map((profile) => [ + profile.id, + contract.corpus.projects.filter( + (project) => project.tailwindProfile === profile.id, + ).length, + ]), + ), + ) + ) { + fail(`${value.host.id} summary is incomplete`); + } + if (JSON.stringify(value.projects?.map((project) => project.id)) !== JSON.stringify(expectedProjects)) { + fail(`${value.host.id} project order or coverage drifted`); + } + for (const project of value.projects) { + const expectedProject = projectContract.get(project.id); + const expectedProfile = contract.authority.tailwindProfiles.find( + (profile) => profile.id === expectedProject.tailwindProfile, + ); + if ( + project.passed !== true || + project.adapter !== expectedProject.adapter || + project.tailwindProfile !== expectedProject.tailwindProfile || + project.tailwind?.version !== expectedProfile.version || + project.migration?.tailwindRetained !== true || + project.migration?.rollback?.exact !== true || + !Number.isInteger(project.source?.classGroups) || + project.source.classGroups < 1 || + project.migration?.replacements !== project.source.classGroups || + project.equivalence?.domEquivalent !== true || + project.equivalence?.ariaEquivalent !== true || + project.equivalence?.computedStyleEquivalent !== true || + project.equivalence?.geometryEquivalent !== true || + project.tailwind?.sourceInventory?.dynamic !== 0 || + project.tailwind?.sourceInventory?.unsupported !== 0 || + project.tailwind?.sourceInventory?.preflightReliance !== "not-observed" || + project.tailwind?.audit?.severities?.error !== 0 || + !project.tailwind?.audit?.codes?.includes("PCSS-AUDIT-000") + ) { + fail(`${value.host.id}/${project.id} did not close every required gate`); + } + if ( + expectedProject.adapter === "vite" && + (project.vite?.version !== + contract.authority.adapters.find((adapter) => adapter.id === "vite").version || + !project.vite?.baseline?.assets?.length || + !project.vite?.candidate?.assets?.length) + ) { + fail(`${value.host.id}/${project.id} has no complete Vite production-build evidence`); + } + if ( + !Number.isFinite(project.equivalence.maximumObservedGeometryDeltaCssPx) || + project.equivalence.maximumObservedGeometryDeltaCssPx > + contract.authority.comparison.maximumLayoutGeometryDeltaCssPx + ) { + fail(`${value.host.id}/${project.id} exceeded the geometry bound`); + } + } + hosts.push({ + id: value.host.id, + os: value.host.os, + arch: value.host.arch, + browser: value.host.browser, + browserVersion: value.host.browserVersion, + evidenceSha256: sha256(readFileSync(path)), + projects: value.summary.projects, + passed: true, + }); +} +hosts.sort((left, right) => left.id.localeCompare(right.id, "en")); + +const [commit, gitTree] = sourceIdentity.split(":"); +const checkoutCommit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repositoryRoot, + encoding: "utf8", +}).trim(); +const checkoutTree = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { + cwd: repositoryRoot, + encoding: "utf8", +}).trim(); +if (commit !== checkoutCommit || gitTree !== checkoutTree) { + fail("host evidence does not belong to the matrix checkout"); +} + +const pliegorsEvidenceBytes = readFileSync(options.pliegorsEvidence); +const pliegors = JSON.parse(pliegorsEvidenceBytes.toString("utf8")); +const expectedPliegorsContract = JSON.parse( + readFileSync(resolve(repositoryRoot, "integration-tests", "pliegors-smoke", "pliegors-contract.json"), "utf8"), +); +if ( + pliegors.schema !== "pliegocss/pliegors-browser-gate/2" || + pliegors.passed !== true || + JSON.stringify(pliegors.pliegorsContract) !== JSON.stringify(expectedPliegorsContract) || + pliegors.pliegocssSource?.dirty !== false || + pliegors.pliegocssSource?.commit !== commit || + pliegors.pliegocssSource?.gitTree !== gitTree || + !pliegors.browser?.product || + pliegors.final?.documentIdentity !== true || + pliegors.final?.nodeIdentity?.island !== true || + pliegors.final?.nodeIdentity?.button !== true || + pliegors.final?.nodeIdentity?.value !== true || + pliegors.final?.eventCount !== 1 || + pliegors.final?.finalMinutes !== pliegors.expected?.finalMinutes || + (pliegors.relevantEvents?.length ?? -1) !== 0 || + (pliegors.serverErrors?.length ?? -1) !== 0 +) { + fail("PliegoRS framework/browser evidence is incomplete or detached from the matrix source"); +} +const matrix = { + schemaVersion: 1, + kind: "pliegocss-adapter-coexistence-matrix", + result: "pass", + source: { commit, gitTree }, + authority: { + sha256: contract.authoritySha256, + corpusSha256: contract.corpusSha256, + }, + coverage: { + hosts: hosts.length, + projectsPerHost: expectedProjects.length, + totalProjectReplays: hosts.length * expectedProjects.length, + adapters: Object.fromEntries( + contract.authority.adapters.map((adapter) => [ + adapter.id, + contract.corpus.projects.filter((project) => project.adapter === adapter.id).length, + ]), + ), + tailwindProfiles: Object.fromEntries( + contract.authority.tailwindProfiles.map((profile) => [ + profile.id, + contract.corpus.projects.filter((project) => project.tailwindProfile === profile.id).length, + ]), + ), + }, + hosts, + pliegorsFramework: { + revision: pliegors.pliegorsContract.revision, + sourceSha256: pliegors.pliegorsContract.sourceSha256, + browser: pliegors.browser.product, + evidenceSha256: sha256(pliegorsEvidenceBytes), + passed: true, + }, + claimBoundary: contract.authority.claimBoundary, +}; +mkdirSync(dirname(options.output), { recursive: true }); +writeFileSync(options.output, `${JSON.stringify(matrix, null, 2)}\n`); +process.stdout.write( + `${JSON.stringify( + { + passed: true, + hosts: hosts.length, + projectsPerHost: expectedProjects.length, + totalProjectReplays: matrix.coverage.totalProjectReplays, + output: relative(repositoryRoot, options.output).replaceAll("\\", "/"), + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-adapter-coexistence-matrix.test.mjs b/scripts/check-adapter-coexistence-matrix.test.mjs new file mode 100644 index 0000000..a82d67a --- /dev/null +++ b/scripts/check-adapter-coexistence-matrix.test.mjs @@ -0,0 +1,174 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { loadContract, readJson, repositoryRoot } from "./adapter-coexistence-v1.mjs"; + +const contract = loadContract(); +const source = { + commit: spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + }).stdout.trim(), + gitTree: spawnSync("git", ["rev-parse", "HEAD^{tree}"], { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + }).stdout.trim(), + dirty: false, +}; +const targetRoot = resolve( + repositoryRoot, + "target", + "adapter-coexistence-certification", + "matrix-contract-test", +); +rmSync(targetRoot, { recursive: true, force: true }); + +function evidence(host) { + const count = contract.corpus.projects.length; + return { + schemaVersion: 1, + kind: "pliegocss-adapter-coexistence-host-evidence", + result: "pass", + source: { ...source }, + authority: { + sha256: contract.authoritySha256, + corpusSha256: contract.corpusSha256, + }, + host: { + ...host, + browserVersion: "Chromium contract test", + playwright: "1.61.1", + }, + summary: { + projects: count, + passed: count, + domEquivalent: count, + ariaEquivalent: count, + computedStyleEquivalent: count, + geometryEquivalent: count, + exactRollbacks: count, + adapters: Object.fromEntries( + contract.authority.adapters.map((adapter) => [ + adapter.id, + contract.corpus.projects.filter((project) => project.adapter === adapter.id).length, + ]), + ), + tailwindProfiles: Object.fromEntries( + contract.authority.tailwindProfiles.map((profile) => [ + profile.id, + contract.corpus.projects.filter( + (project) => project.tailwindProfile === profile.id, + ).length, + ]), + ), + }, + projects: contract.corpus.projects.map((project) => ({ + id: project.id, + adapter: project.adapter, + tailwindProfile: project.tailwindProfile, + passed: true, + source: { classGroups: 3 }, + tailwind: { + version: contract.authority.tailwindProfiles.find( + (profile) => profile.id === project.tailwindProfile, + ).version, + sourceInventory: { dynamic: 0, unsupported: 0, preflightReliance: "not-observed" }, + audit: { severities: { error: 0 }, codes: ["PCSS-AUDIT-000"] }, + }, + migration: { replacements: 3, tailwindRetained: true, rollback: { exact: true } }, + vite: + project.adapter === "vite" + ? { + version: contract.authority.adapters.find((adapter) => adapter.id === "vite").version, + baseline: { assets: [{ path: "index.html" }] }, + candidate: { assets: [{ path: "index.html" }] }, + } + : null, + equivalence: { + domEquivalent: true, + ariaEquivalent: true, + computedStyleEquivalent: true, + geometryEquivalent: true, + maximumObservedGeometryDeltaCssPx: 0, + }, + })), + }; +} + +function pliegorsEvidence() { + return { + schema: "pliegocss/pliegors-browser-gate/2", + passed: true, + pliegocssSource: { ...source }, + pliegorsContract: readJson( + resolve(repositoryRoot, "integration-tests", "pliegors-smoke", "pliegors-contract.json"), + ), + browser: { product: "Chrome contract test" }, + expected: { finalMinutes: 20 }, + final: { + documentIdentity: true, + nodeIdentity: { island: true, button: true, value: true }, + eventCount: 1, + finalMinutes: 20, + }, + relevantEvents: [], + serverErrors: [], + }; +} + +function writeCase(name, mutate = () => {}) { + const root = join(targetRoot, name); + mkdirSync(root, { recursive: true }); + const documents = contract.authority.hosts.map(evidence); + const pliegors = pliegorsEvidence(); + mutate(documents, pliegors); + for (const document of documents) { + writeFileSync(join(root, `${document.host.id}.json`), `${JSON.stringify(document, null, 2)}\n`); + } + const pliegorsPath = join(root, "pliegors-framework.json"); + writeFileSync(pliegorsPath, `${JSON.stringify(pliegors, null, 2)}\n`); + return { root, pliegorsPath }; +} + +function run({ root, pliegorsPath }) { + return spawnSync( + process.execPath, + [ + resolve(repositoryRoot, "scripts", "check-adapter-coexistence-matrix.mjs"), + `--evidence-root=${relative(repositoryRoot, root)}`, + `--pliegors-evidence=${relative(repositoryRoot, pliegorsPath)}`, + `--output=${relative(repositoryRoot, join(root, "matrix.json"))}`, + ], + { cwd: repositoryRoot, encoding: "utf8", windowsHide: true }, + ); +} + +const valid = run(writeCase("valid")); +if (valid.status !== 0) throw new Error(`valid matrix failed\n${valid.stdout}${valid.stderr}`); + +for (const [name, mutate] of [ + ["missing-host", (documents) => documents.pop()], + ["dirty-source", (documents) => { + documents[0].source.dirty = true; + }], + ["aria-drift", (documents) => { + documents[1].projects[0].equivalence.ariaEquivalent = false; + }], + ["audit-error", (documents) => { + documents[2].projects[0].tailwind.audit.severities.error = 1; + }], + ["vite-evidence-missing", (documents) => { + documents[0].projects.find((project) => project.adapter === "vite").vite = null; + }], + ["pliegors-dirty-source", (_documents, pliegors) => { + pliegors.pliegocssSource.dirty = true; + }], +]) { + const result = run(writeCase(name, mutate)); + if (result.status === 0) throw new Error(`${name} matrix unexpectedly passed`); +} + +rmSync(targetRoot, { recursive: true, force: true }); +process.stdout.write("adapter coexistence matrix fail-closed contract: pass\n"); diff --git a/scripts/check-benchmark-authority-v2.mjs b/scripts/check-benchmark-authority-v2.mjs new file mode 100644 index 0000000..cf569fd --- /dev/null +++ b/scripts/check-benchmark-authority-v2.mjs @@ -0,0 +1,167 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + authorityPath, + canonicalLaneMap, + corpusMetrics, + loadAuthority, + materializeCorpus, + readInstalledPackage, + repositoryRoot, + resolveLaneCli, + sha256, +} from "./benchmark-authority-v2.mjs"; + +const network = process.argv.slice(2).includes("--network"); +if (process.argv.length > (network ? 3 : 2)) { + throw new Error("usage: node scripts/check-benchmark-authority-v2.mjs [--network]"); +} + +function fail(message) { + throw new Error(`Benchmark Authority v2: ${message}`); +} + +function equal(actual, expected, label) { + if (actual !== expected) fail(`${label}: expected ${JSON.stringify(expected)}, found ${JSON.stringify(actual)}`); +} + +function nonEmpty(value, label) { + if (typeof value !== "string" || value.trim() === "") fail(`${label} must be non-empty`); +} + +const authorityBytes = readFileSync(authorityPath); +const authority = loadAuthority(); +equal(authority.schemaVersion, 2, "schemaVersion"); +equal(authority.status, "active", "status"); +equal(authority.registry, "https://registry.npmjs.org", "registry"); +equal(authority.primaryLane, "tailwind-latest", "primaryLane"); +equal(authority.resetContract, "no-preflight", "resetContract"); +equal(authority.maximumAgeHours, 168, "maximumAgeHours"); + +const observedAt = Date.parse(authority.observedAtUtc); +const expiresAt = Date.parse(authority.expiresAtUtc); +if (!Number.isFinite(observedAt) || !Number.isFinite(expiresAt)) fail("oracle dates must be ISO timestamps"); +equal(expiresAt - observedAt, authority.maximumAgeHours * 60 * 60 * 1_000, "oracle validity window"); +const now = Date.now(); +if (observedAt > now + 5 * 60 * 1_000) fail("oracle observation is in the future"); +if (now > expiresAt) fail(`oracle expired at ${authority.expiresAtUtc}; refresh it before comparison`); + +const expectedLaneIds = ["tailwind-latest", "tailwind-v3-lts", "tailwind-frozen-release"]; +if (!Array.isArray(authority.lanes)) fail("lanes must be an array"); +equal(JSON.stringify(authority.lanes.map((lane) => lane.id)), JSON.stringify(expectedLaneIds), "lane order"); +const laneMap = canonicalLaneMap(authority); +equal(laneMap.get("tailwind-latest")?.distTag, "latest", "latest dist-tag"); +equal(laneMap.get("tailwind-v3-lts")?.distTag, "v3-lts", "v3 LTS dist-tag"); +equal(laneMap.get("tailwind-frozen-release")?.distTag, null, "frozen lane dist-tag"); +if (laneMap.get("tailwind-frozen-release")?.version === laneMap.get("tailwind-latest")?.version) { + fail("frozen release must remain distinct from the current competitor lane"); +} + +const packageJson = JSON.parse(readFileSync(resolve(repositoryRoot, "package.json"), "utf8")); +const lockText = readFileSync(resolve(repositoryRoot, "pnpm-lock.yaml"), "utf8"); +for (const lane of authority.lanes) { + for (const field of ["role", "packageAlias", "packageName", "version", "integrity", "cliPackageAlias", "cliPackageName", "cliVersion", "cliIntegrity"]) { + nonEmpty(lane[field], `${lane.id}.${field}`); + } + const packageSpec = packageJson.devDependencies?.[lane.packageAlias]; + const expectedPackageSpec = + lane.packageAlias === lane.packageName ? lane.version : `npm:${lane.packageName}@${lane.version}`; + equal(packageSpec, expectedPackageSpec, `${lane.id} package pin`); + const cliSpec = packageJson.devDependencies?.[lane.cliPackageAlias]; + const expectedCliSpec = + lane.cliPackageAlias === lane.cliPackageName + ? lane.cliVersion + : `npm:${lane.cliPackageName}@${lane.cliVersion}`; + equal(cliSpec, expectedCliSpec, `${lane.id} CLI pin`); + + const installedPackage = readInstalledPackage(lane.packageAlias).value; + equal(installedPackage.name, lane.packageName, `${lane.id} installed package name`); + equal(installedPackage.version, lane.version, `${lane.id} installed package version`); + const installedCli = readInstalledPackage(lane.cliPackageAlias).value; + equal(installedCli.name, lane.cliPackageName, `${lane.id} installed CLI name`); + equal(installedCli.version, lane.cliVersion, `${lane.id} installed CLI version`); + resolveLaneCli(lane); + if (!lockText.includes(lane.integrity) || !lockText.includes(lane.cliIntegrity)) { + fail(`${lane.id} registry integrity is not bound by pnpm-lock.yaml`); + } +} + +const expectedCorpora = ["micro", "medium", "large"]; +if (!Array.isArray(authority.corpora)) fail("corpora must be an array"); +equal(JSON.stringify(authority.corpora.map((corpus) => corpus.id)), JSON.stringify(expectedCorpora), "corpus order"); +const corpusResults = []; +for (const corpus of authority.corpora) { + if (!Number.isSafeInteger(corpus.repeat) || corpus.repeat < 1) fail(`${corpus.id}.repeat is invalid`); + nonEmpty(corpus.source, `${corpus.id}.source`); + nonEmpty(corpus.scope, `${corpus.id}.scope`); + const html = materializeCorpus(corpus); + const metrics = corpusMetrics(html); + for (const field of ["sha256", "htmlBytes", "classAttributeCount", "utilityOccurrences", "uniqueUtilityTokens", "uniqueStyleInputs"]) { + equal(corpus.expected?.[field], metrics[field], `${corpus.id}.expected.${field}`); + } + corpusResults.push({ id: corpus.id, ...metrics }); +} +if (!(corpusResults[0].htmlBytes < corpusResults[1].htmlBytes && corpusResults[1].htmlBytes < corpusResults[2].htmlBytes)) { + fail("corpus byte sizes must increase micro < medium < large"); +} + +const methodology = authority.methodology; +equal(methodology.design, "paired-interleaved-alternating-order", "methodology.design"); +equal(methodology.warmupPairs, 5, "methodology.warmupPairs"); +equal(methodology.measuredPairs, 30, "methodology.measuredPairs"); +equal(methodology.freshProcess, true, "methodology.freshProcess"); +equal(methodology.freshOutputVerification, true, "methodology.freshOutputVerification"); +equal(methodology.completeSamples, true, "methodology.completeSamples"); +equal(methodology.compression?.gzipLevel, 9, "methodology gzip"); +equal(methodology.compression?.brotliQuality, 11, "methodology Brotli"); +nonEmpty(methodology.memoryMetric, "methodology.memoryMetric"); +nonEmpty(methodology.scorePolicy, "methodology.scorePolicy"); + +const requiredFiles = [ + "scripts/benchmark-process-metrics.rs", + "scripts/measure-benchmark-authority-v2.mjs", + "scripts/render-benchmark-authority-v2.mjs", + authority.artifacts.generatedDocumentation, +]; +for (const path of requiredFiles) { + readFileSync(resolve(repositoryRoot, path)); +} +const legacyGate = readFileSync(resolve(repositoryRoot, "scripts/measure-pliego-gate-b.mjs"), "utf8"); +if (legacyGate.includes("frozenTailwind") || legacyGate.includes("frozen_tailwind_baseline")) { + fail("Gate B still owns a handwritten Tailwind baseline"); +} + +const networkResults = []; +if (network) { + const responses = new Map(); + for (const packageName of new Set(authority.lanes.flatMap((lane) => [lane.packageName, lane.cliPackageName]))) { + const response = await fetch(`${authority.registry}/${packageName.replaceAll("/", "%2f")}`); + if (!response.ok) fail(`registry request for ${packageName} returned HTTP ${response.status}`); + responses.set(packageName, await response.json()); + } + for (const lane of authority.lanes) { + const packageMetadata = responses.get(lane.packageName); + if (lane.distTag) equal(packageMetadata["dist-tags"]?.[lane.distTag], lane.version, `${lane.id} live dist-tag`); + equal(packageMetadata.versions?.[lane.version]?.dist?.integrity, lane.integrity, `${lane.id} live package integrity`); + const cliMetadata = responses.get(lane.cliPackageName); + equal(cliMetadata.versions?.[lane.cliVersion]?.dist?.integrity, lane.cliIntegrity, `${lane.id} live CLI integrity`); + networkResults.push({ id: lane.id, registryVerified: true }); + } +} + +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 2, + status: "passed", + authoritySha256: sha256(authorityBytes), + observedAtUtc: authority.observedAtUtc, + expiresAtUtc: authority.expiresAtUtc, + lanes: authority.lanes.map((lane) => ({ id: lane.id, version: lane.version })), + corpora: corpusResults, + network: network ? networkResults : "not-requested", + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-benchmark-result-v2.mjs b/scripts/check-benchmark-result-v2.mjs new file mode 100644 index 0000000..7bcd62c --- /dev/null +++ b/scripts/check-benchmark-result-v2.mjs @@ -0,0 +1,243 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + authorityPath, + loadAuthority, + readInstalledPackage, + repositoryRoot, + round, + sha256, + summarize, +} from "./benchmark-authority-v2.mjs"; + +const arguments_ = process.argv.slice(2); +let canonical = false; +let resultPath = resolve( + repositoryRoot, + "benchmarks", + "results", + "benchmark-authority-v2.local.json", +); +for (const argument of arguments_) { + if (argument === "--") continue; + else if (argument === "--canonical") canonical = true; + else if (argument.startsWith("--result=")) resultPath = resolve(repositoryRoot, argument.slice(9)); + else throw new Error("usage: node scripts/check-benchmark-result-v2.mjs [--canonical] [--result=]"); +} + +function fail(message) { + throw new Error(`Benchmark result schema 2: ${message}`); +} +function equal(actual, expected, label) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(`${label} drifted`); + } +} +function positive(value, label, allowNegative = false) { + if (!Number.isFinite(value) || (!allowNegative && value <= 0)) fail(`${label} is invalid`); +} +function checkSummary(values, document, unit, label) { + equal(document, summarize(values, unit), label); +} +function checkCurrentFile(document, expectedPath, label) { + const repositoryPath = relative(repositoryRoot, expectedPath).replaceAll("\\", "/"); + if (document?.path !== repositoryPath) fail(`${label} path drifted`); + if (document?.sha256 !== sha256(readFileSync(expectedPath))) fail(`${label} hash drifted`); +} +function git(arguments_) { + const result = spawnSync("git", arguments_, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) fail(`git ${arguments_.join(" ")} failed`); + return result.stdout; +} +function currentGitState() { + const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]); + return { + commit: git(["rev-parse", "HEAD"]).trim(), + dirty: status.length > 0, + statusEntryCount: status.split("\0").filter(Boolean).length, + statusSha256: sha256(Buffer.from(status, "utf8")), + }; +} + +const resultChild = relative(repositoryRoot, resultPath); +if (!resultChild || resultChild === ".." || resultChild.startsWith(`..${sep}`) || isAbsolute(resultChild)) { + fail("result path escapes the repository"); +} +const authority = loadAuthority(); +const document = JSON.parse(readFileSync(resultPath, "utf8")); +if (document.schemaVersion !== 2) fail("schemaVersion must be 2"); +if (document.benchmark !== "PliegoCSS Benchmark Authority v2 paired competitor oracle") { + fail("benchmark identity drifted"); +} +if (document.authority?.sha256 !== sha256(readFileSync(authorityPath))) fail("authority hash drifted"); +if (document.authority?.path !== "benchmarks/benchmark-authority-v2/oracle.json") { + fail("authority path drifted"); +} +if (document.authority?.observedAtUtc !== authority.observedAtUtc) fail("authority observation drifted"); +if (document.authority?.expiresAtUtc !== authority.expiresAtUtc) fail("authority expiration drifted"); +if (document.authority?.primaryLane !== authority.primaryLane) fail("primary lane drifted"); +if (document.authority?.resetContract !== "no-preflight") fail("reset contract drifted"); +const generatedAt = Date.parse(document.generatedAtUtc); +if (!Number.isFinite(generatedAt)) fail("generatedAtUtc is invalid"); +if (generatedAt < Date.parse(authority.observedAtUtc) || generatedAt > Date.parse(authority.expiresAtUtc)) { + fail("result was not generated inside the oracle validity window"); +} +if (document.methodology?.design !== authority.methodology.design) fail("pair design drifted"); +if (canonical) { + if (document.mode === "smoke") fail("canonical result cannot be smoke evidence"); + if (document.methodology.warmupPairs !== authority.methodology.warmupPairs) fail("warmup count drifted"); + if (document.methodology.measuredPairs !== authority.methodology.measuredPairs) fail("pair count drifted"); +} +if ( + !document.methodology?.freshProcess || + !document.methodology?.completeSamples || + !document.methodology?.freshOutputVerification +) { + fail("fresh processes, complete samples, and fresh verification are required"); +} +if (document.methodology.pairOrder !== authority.methodology.pairOrder) fail("pair order drifted"); +if (document.methodology.memoryMetric !== authority.methodology.memoryMetric) fail("memory metric drifted"); +equal(document.methodology.compression, authority.methodology.compression, "compression contract"); +if (document.claimBoundary !== authority.methodology.scorePolicy) fail("claim boundary drifted"); + +const harnessPath = resolve(repositoryRoot, "scripts", "measure-benchmark-authority-v2.mjs"); +const runnerPath = resolve(repositoryRoot, "scripts", "benchmark-process-metrics.rs"); +checkCurrentFile(document.provenance?.harness, harnessPath, "harness provenance"); +checkCurrentFile(document.provenance?.processMetricsRunner, runnerPath, "process runner provenance"); +if (document.provenance?.packageJsonSha256 !== sha256(readFileSync(resolve(repositoryRoot, "package.json")))) { + fail("package.json provenance drifted"); +} +if (document.provenance?.pnpmLockSha256 !== sha256(readFileSync(resolve(repositoryRoot, "pnpm-lock.yaml")))) { + fail("pnpm lock provenance drifted"); +} +equal(document.provenance?.git, currentGitState(), "Git source state"); +if (document.mode === "immutable-evidence" && document.provenance.git.dirty) { + fail("immutable evidence was captured from a dirty source tree"); +} +const executablePath = join( + repositoryRoot, + document.environment?.cargoTarget ?? "", + document.environment?.toolchain?.host ?? "", + "release", + process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", +); +if (document.provenance?.executableSha256 !== sha256(readFileSync(executablePath))) { + fail("measured PliegoCSS executable provenance drifted"); +} +if (document.mode === "immutable-evidence") { + const securityStatus = document.environment?.securityTooling?.status; + if (!new Set(["active", "disabled", "not-installed", "unknown"]).has(securityStatus)) { + fail("immutable evidence has no explicit security-tooling state"); + } +} + +const expectedLanes = canonical + ? authority.lanes.map((lane) => lane.id) + : Object.keys(document.lanes ?? {}); +if (expectedLanes.length === 0) fail("no measured lanes"); +if (canonical) equal(Object.keys(document.lanes), expectedLanes, "lane set"); +const expectedCorpora = authority.corpora.map((corpus) => corpus.id); +let comparisons = 0; +for (const laneId of expectedLanes) { + const laneAuthority = authority.lanes.find((lane) => lane.id === laneId); + const lane = document.lanes[laneId]; + if (!laneAuthority || !lane) fail(`unknown or missing lane ${laneId}`); + if (lane.version !== laneAuthority.version || lane.cliVersion !== laneAuthority.cliVersion) { + fail(`${laneId} version drifted`); + } + if ( + lane.packageManifestSha256 !== + sha256(readFileSync(readInstalledPackage(laneAuthority.packageAlias).path)) || + lane.cliManifestSha256 !== + sha256(readFileSync(readInstalledPackage(laneAuthority.cliPackageAlias).path)) + ) { + fail(`${laneId} installed package provenance drifted`); + } + if (canonical) equal(Object.keys(lane.corpora), expectedCorpora, `${laneId} corpus set`); + for (const [corpusId, comparison] of Object.entries(lane.corpora)) { + const corpusAuthority = authority.corpora.find((corpus) => corpus.id === corpusId); + if (!corpusAuthority) fail(`${laneId} has unknown corpus ${corpusId}`); + equal(comparison.fixture, corpusAuthority.expected, `${laneId}/${corpusId} fixture`); + const pairs = comparison.pairs; + if (!Array.isArray(pairs) || pairs.length !== document.methodology.measuredPairs) { + fail(`${laneId}/${corpusId} pair count drifted`); + } + const pliegoLatency = []; + const tailwindLatency = []; + const deltas = []; + const ratios = []; + const pliegoMemory = []; + const tailwindMemory = []; + const memoryDeltas = []; + for (const [index, pair] of pairs.entries()) { + const expectedOrder = index % 2 === 0 ? ["pliego", "tailwind"] : ["tailwind", "pliego"]; + if (pair.pair !== index + 1) fail(`${laneId}/${corpusId} pair index drifted`); + equal(pair.order, expectedOrder, `${laneId}/${corpusId} pair order`); + for (const kind of ["pliego", "tailwind"]) { + positive(pair[kind]?.elapsedNs, `${laneId}/${corpusId} ${kind} elapsed`); + positive(pair[kind]?.peakWorkingSetBytes, `${laneId}/${corpusId} ${kind} memory`); + if (pair[kind].exitCode !== 0) fail(`${laneId}/${corpusId} ${kind} failed`); + } + if (pair.deltaNs !== pair.pliego.elapsedNs - pair.tailwind.elapsedNs) fail("paired latency delta drifted"); + if (pair.tailwindToPliegoRatio !== round(pair.tailwind.elapsedNs / pair.pliego.elapsedNs)) { + fail("paired latency ratio drifted"); + } + if (pair.peakWorkingSetDeltaBytes !== pair.pliego.peakWorkingSetBytes - pair.tailwind.peakWorkingSetBytes) { + fail("paired memory delta drifted"); + } + pliegoLatency.push(pair.pliego.elapsedNs / 1_000_000); + tailwindLatency.push(pair.tailwind.elapsedNs / 1_000_000); + deltas.push(pair.deltaNs / 1_000_000); + ratios.push(pair.tailwindToPliegoRatio); + pliegoMemory.push(pair.pliego.peakWorkingSetBytes); + tailwindMemory.push(pair.tailwind.peakWorkingSetBytes); + memoryDeltas.push(pair.peakWorkingSetDeltaBytes); + } + const summaries = comparison.summaries; + checkSummary(pliegoLatency, summaries.pliegoLatency, "ms", "Pliego latency summary"); + checkSummary(tailwindLatency, summaries.tailwindLatency, "ms", "Tailwind latency summary"); + checkSummary(deltas, summaries.pairedLatencyDelta, "ms", "paired latency summary"); + checkSummary(ratios, summaries.tailwindToPliegoRatio, "ratio", "paired ratio summary"); + checkSummary(pliegoMemory, summaries.pliegoPeakWorkingSet, "bytes", "Pliego memory summary"); + checkSummary(tailwindMemory, summaries.tailwindPeakWorkingSet, "bytes", "Tailwind memory summary"); + checkSummary(memoryDeltas, summaries.pairedPeakWorkingSetDelta, "bytes", "paired memory summary"); + if (!comparison.determinism?.freshOutputVerified || comparison.determinism.measuredPairs !== pairs.length) { + fail(`${laneId}/${corpusId} determinism contract drifted`); + } + if (comparison.coverage?.ratio !== 1 || comparison.coverage.missing?.length !== 0) { + fail(`${laneId}/${corpusId} candidate coverage is incomplete`); + } + for (const kind of ["pliego", "tailwind"]) { + const output = comparison.output[kind]; + for (const part of ["css", "html"]) { + for (const field of ["rawBytes", "gzipBytes", "brotliBytes"]) { + positive(output[part][field], `${laneId}/${corpusId} ${kind} ${part} ${field}`); + } + if (!/^[0-9a-f]{64}$/u.test(output[part].sha256)) fail("output SHA-256 is invalid"); + } + if (comparison.determinism[`${kind}Sha256`] !== output.css.sha256) { + fail(`${laneId}/${corpusId} ${kind} deterministic output hash drifted`); + } + if (output.transferGzipBytes !== output.css.gzipBytes + output.html.gzipBytes) { + fail("gzip transfer sum drifted"); + } + if (output.transferBrotliBytes !== output.css.brotliBytes + output.html.brotliBytes) { + fail("Brotli transfer sum drifted"); + } + } + comparisons += 1; + } +} +if (canonical && comparisons !== authority.lanes.length * authority.corpora.length) { + fail("canonical result is incomplete"); +} + +process.stdout.write( + `${JSON.stringify({ schemaVersion: 2, status: "passed", mode: document.mode, lanes: expectedLanes.length, comparisons, pairsPerComparison: document.methodology.measuredPairs }, null, 2)}\n`, +); diff --git a/scripts/check-breakpoint-order-browser.mjs b/scripts/check-breakpoint-order-browser.mjs new file mode 100644 index 0000000..12ec387 --- /dev/null +++ b/scripts/check-breakpoint-order-browser.mjs @@ -0,0 +1,186 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; + +const browser = process.argv[2]; +if (!browser) throw new Error("browser executable required"); +const root = resolve(import.meta.dirname, ".."); +const cargoEnvironment = isolatedCargoEnvironment(root); +const cargoTarget = cargoTargetRoot(root, cargoEnvironment); +const binary = join( + cargoTarget, + "debug", + process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", +); +const runtime = mkdtempSync(join(tmpdir(), "pliegocss-breakpoint-order-")); +const profile = mkdtempSync(join(tmpdir(), "pliegocss-breakpoint-cdp-")); +const config = join(runtime, "pliego.theme.toml"); +const cssPath = join(runtime, "app.css"); +const manifestPath = join(runtime, "app.manifest.json"); +writeFileSync( + config, + 'schema = 1\nextends = "seed"\n\n[breakpoints]\ncontent-wide = "72rem"\ntablet = "52rem"\n', +); +const build = spawnSync("cargo", ["build", "--quiet", "--locked", "-p", "pliego-cssc"], { + cwd: root, + env: cargoEnvironment, + encoding: "utf8", + windowsHide: true, +}); +if (build.error) throw build.error; +if (build.status !== 0) throw new Error(`compiler build failed:\n${build.stdout}${build.stderr}`); +const compile = spawnSync( + binary, + [ + "compile", + "--style", + "grid grid-cols-1 tablet:grid-cols-2 content-wide:grid-cols-3", + "--config", + config, + "--output", + cssPath, + "--manifest", + manifestPath, + ], + { cwd: root, encoding: "utf8", windowsHide: true }, +); +if (compile.error) throw compile.error; +if (compile.status !== 0) throw new Error(`compiler invocation failed:\n${compile.stdout}${compile.stderr}`); +const css = readFileSync(cssPath, "utf8"); +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +const className = manifest.styles?.[0]?.className; +if (!/^pc_[a-z0-9]+$/u.test(className ?? "")) throw new Error("compiled class name missing"); +const narrowIndex = css.indexOf("(width>=52rem)"); +const wideIndex = css.indexOf("(width>=72rem)"); +if (narrowIndex < 0 || wideIndex <= narrowIndex) { + throw new Error(`compiled media order is not 52rem before 72rem:\n${css}`); +} + +const html = `
`; +const server = createServer((request, response) => { + if (request.url === "/app.css") { + response.writeHead(200, { "content-type": "text/css" }); + response.end(css); + return; + } + response.writeHead(200, { "content-type": "text/html" }); + response.end(html); +}); +await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); +const port = server.address().port; +const debugPort = 10_200 + Math.floor(Math.random() * 300); +const child = spawn( + browser, + [ + "--headless=new", + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profile}`, + "--no-first-run", + "--disable-default-apps", + `http://127.0.0.1:${port}/`, + ], + { stdio: "ignore" }, +); + +try { + let target; + let version; + for (let attempt = 0; attempt < 60; attempt++) { + try { + version = await (await fetch(`http://127.0.0.1:${debugPort}/json/version`)).json(); + const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json(); + target = targets.find((entry) => entry.type === "page" && entry.url.includes(`:${port}`)); + if (target) break; + } catch {} + await delay(100); + } + if (!target) throw new Error("CDP target unavailable"); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((open, reject) => { + socket.addEventListener("open", open, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + let id = 0; + const call = (method, params = {}) => + new Promise((resolveCall, reject) => { + const current = ++id; + const listener = (event) => { + const message = JSON.parse(event.data); + if (message.id !== current) return; + socket.removeEventListener("message", listener); + message.error ? reject(new Error(message.error.message)) : resolveCall(message.result); + }; + socket.addEventListener("message", listener); + socket.send(JSON.stringify({ id: current, method, params })); + }); + await call("Page.enable"); + const observations = []; + for (const [width, expectedColumns] of [ + [800, 1], + [900, 2], + [1200, 3], + ]) { + await call("Emulation.setDeviceMetricsOverride", { + width, + height: 800, + deviceScaleFactor: 1, + mobile: false, + }); + await delay(100); + const evaluation = await call("Runtime.evaluate", { + returnByValue: true, + expression: + "(() => getComputedStyle(document.querySelector('#grid')).gridTemplateColumns.split(' ').filter(Boolean).length)()", + }); + const actualColumns = evaluation.result.value; + observations.push({ width, columns: actualColumns }); + if (actualColumns !== expectedColumns) { + throw new Error( + `breakpoint regression at ${width}px: expected ${expectedColumns}, found ${actualColumns}`, + ); + } + } + socket.close(); + process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + browser: version.Browser, + breakpoints: ["52rem", "72rem"], + compiledOrder: "narrow-to-wide", + observations, + }, + null, + 2, + )}\n`, + ); +} finally { + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }); + } else { + child.kill(); + } + server.close(); + await delay(300); + for (const directory of [profile, runtime]) { + for (let attempt = 0; attempt < 20; attempt++) { + try { + rmSync(directory, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 }); + break; + } catch (error) { + if (attempt === 19) { + process.stderr.write(`warning: deferred browser temp cleanup for ${directory}: ${error.message}\n`); + break; + } + await delay(100); + } + } + } +} diff --git a/scripts/check-browser-output-authority.mjs b/scripts/check-browser-output-authority.mjs new file mode 100644 index 0000000..50da535 --- /dev/null +++ b/scripts/check-browser-output-authority.mjs @@ -0,0 +1,188 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { repositoryRoot, sha256 } from "./benchmark-authority-v2.mjs"; + +const authorityPath = resolve( + repositoryRoot, + "benchmarks", + "browser-output-certification-v1", + "authority.json", +); +const authority = JSON.parse(readFileSync(authorityPath, "utf8")); + +function fail(message) { + throw new Error(`Browser/output authority: ${message}`); +} + +function equal(actual, expected, label) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(`${label} drifted`); +} + +function nonEmpty(value, label) { + if (typeof value !== "string" || value.trim() === "") fail(`${label} must be non-empty`); +} + +if (authority.schemaVersion !== 1 || authority.status !== "active-contract") { + fail("header drifted"); +} +if (authority.kind !== "pliegocss-browser-output-certification") fail("kind drifted"); +if (authority.competitorAuthority !== "benchmarks/benchmark-authority-v2/oracle.json") { + fail("competitor authority path drifted"); +} +if (authority.competitorLane !== "tailwind-latest") fail("competitor lane drifted"); +if (authority.maximumEvidenceAgeHours !== 168) fail("evidence validity drifted"); + +for (const input of [authority.fixture, authority.reset]) { + if (!/^[0-9a-f]{64}$/u.test(input.sha256)) fail(`${input.path} hash is invalid`); + const bytes = readFileSync(resolve(repositoryRoot, input.path)); + if (sha256(bytes) !== input.sha256) fail(`${input.path} hash drifted`); +} +if (!readFileSync(resolve(repositoryRoot, authority.reset.path), "utf8").startsWith("@layer reset")) { + fail("shared reset must be isolated in the lowest cascade layer"); +} + +equal(authority.resetModes.map((mode) => mode.id), ["no-reset", "shared-reset"], "reset modes"); +authority.resetModes.forEach((mode) => nonEmpty(mode.contract, `${mode.id}.contract`)); +equal(authority.browsers, ["chromium", "firefox", "webkit"], "browser engines"); + +const expectedHosts = [ + ["windows", "windows-latest", "win32", "x64"], + ["linux", "ubuntu-24.04", "linux", "x64"], + ["macos", "macos-15", "darwin", "arm64"], +].flatMap(([prefix, runner, os, arch]) => + authority.browsers.map((browser) => ({ + id: `${prefix}-${arch === "x64" ? "x64" : "arm64"}-${browser}`, + runner, + os, + arch, + browser, + })), +); +equal(authority.hosts, expectedHosts, "host matrix"); + +if ( + !Array.isArray(authority.computedProperties) || + authority.computedProperties.length < 40 || + new Set(authority.computedProperties).size !== authority.computedProperties.length +) { + fail("computed property set is incomplete or contains duplicates"); +} +for (const required of [ + "display", + "width", + "padding-top", + "grid-template-columns", + "color", + "background-color", + "border-top-width", + "outline-width", + "font-size", + "opacity", +]) { + if (!authority.computedProperties.includes(required)) fail(`computed property ${required} is missing`); +} + +equal( + authority.scenarios.map(({ id, width, height, action }) => ({ id, width, height, action })), + [ + { id: "base-mobile", width: 375, height: 812, action: "none" }, + { id: "base-tablet", width: 768, height: 1024, action: "none" }, + { id: "base-desktop", width: 1440, height: 900, action: "none" }, + { id: "hover-primary", width: 1440, height: 900, action: "hover" }, + { id: "focus-input", width: 1440, height: 900, action: "focus" }, + ], + "scenario contract", +); +for (const scenario of authority.scenarios.filter((entry) => entry.action !== "none")) { + nonEmpty(scenario.selector, `${scenario.id}.selector`); +} + +equal( + authority.requiredCoverage, + [ + "computed-style", + "layout-geometry", + "responsive", + "interactive-state", + "reset-explicit", + "screenshot-bounded", + "tailwind-latest", + ], + "coverage contract", +); +if (authority.comparison.computedLengthQuantumCssPx !== 0.25) fail("length quantum drifted"); +if ( + authority.comparison.layoutGeometryResolutionCssPx !== 0.015625 || + authority.comparison.maximumLayoutGeometryDeltaCssPx !== 0.1 +) { + fail("layout geometry precision or tolerance drifted"); +} +if (authority.comparison.screenshotPixelThreshold !== 0.2) fail("pixel threshold drifted"); +if (authority.comparison.maximumScreenshotMismatchRatio !== 0.005) { + fail("screenshot mismatch budget drifted"); +} +for (const field of ["computedStyle", "layoutGeometry", "screenshots", "claimBoundary"]) { + nonEmpty(authority.comparison[field], `comparison.${field}`); +} + +const packageJson = JSON.parse(readFileSync(resolve(repositoryRoot, "package.json"), "utf8")); +const expectedPackages = { + playwright: "1.61.1", + pixelmatch: "7.2.0", + pngjs: "7.0.0", +}; +for (const [name, version] of Object.entries(expectedPackages)) { + if (packageJson.devDependencies?.[name] !== version) fail(`${name} must be pinned to ${version}`); + const installed = JSON.parse( + readFileSync(resolve(repositoryRoot, "node_modules", name, "package.json"), "utf8"), + ); + if (installed.version !== version) fail(`installed ${name} version drifted`); +} + +const runnerSource = readFileSync( + resolve(repositoryRoot, "scripts", "browser-output-certification.mjs"), + "utf8", +); +for (const marker of [ + "getComputedStyle", + "getBoundingClientRect", + "page.screenshot", + "pixelmatch", + "--require-clean", + "authority.competitorLane", +]) { + if (!runnerSource.includes(marker)) fail(`runner is missing ${marker}`); +} +const workflow = readFileSync( + resolve(repositoryRoot, ".github", "workflows", "browser-output-certification.yml"), + "utf8", +); +for (const host of authority.hosts) { + if (!workflow.includes(`host: ${host.id}`)) fail(`workflow is missing ${host.id}`); +} +for (const marker of [ + "playwright install", + "--require-clean", + "check:browser-output-matrix", + "actions/upload-artifact@", + "actions/download-artifact@", +]) { + if (!workflow.includes(marker)) fail(`workflow is missing ${marker}`); +} + +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + status: "passed", + authoritySha256: sha256(readFileSync(authorityPath)), + hosts: authority.hosts.length, + browsers: authority.browsers, + resetModes: authority.resetModes.map((mode) => mode.id), + scenarios: authority.scenarios.length, + computedProperties: authority.computedProperties.length, + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-browser-output-matrix.mjs b/scripts/check-browser-output-matrix.mjs new file mode 100644 index 0000000..2dafccc --- /dev/null +++ b/scripts/check-browser-output-matrix.mjs @@ -0,0 +1,341 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { repositoryRoot, sha256 } from "./benchmark-authority-v2.mjs"; + +const authorityPath = resolve( + repositoryRoot, + "benchmarks", + "browser-output-certification-v1", + "authority.json", +); +const authority = JSON.parse(readFileSync(authorityPath, "utf8")); +const competitorPath = resolve(repositoryRoot, authority.competitorAuthority); +const competitorBytes = readFileSync(competitorPath); +const competitor = JSON.parse(competitorBytes); +const lane = competitor.lanes.find((candidate) => candidate.id === authority.competitorLane); +if (!lane) throw new Error(`Browser/output matrix: missing ${authority.competitorLane}`); +if (Date.now() > Date.parse(competitor.expiresAtUtc)) { + throw new Error("Browser/output matrix: competitor authority expired"); +} +const tailwindManifestSha256 = sha256( + readFileSync(resolve(repositoryRoot, "node_modules", lane.packageAlias, "package.json")), +); +const tailwindCliManifestSha256 = sha256( + readFileSync(resolve(repositoryRoot, "node_modules", ...lane.cliPackageAlias.split("/"), "package.json")), +); +const arguments_ = process.argv.slice(2).filter((argument) => argument !== "--"); +let evidenceRoot = resolve( + repositoryRoot, + "target", + "browser-output-certification", + "incoming", +); +let outputPath = resolve( + repositoryRoot, + "target", + "browser-output-certification", + "browser-output-matrix.json", +); +for (const argument of arguments_) { + if (argument.startsWith("--evidence-root=")) evidenceRoot = resolve(repositoryRoot, argument.slice(16)); + else if (argument.startsWith("--output=")) outputPath = resolve(repositoryRoot, argument.slice(9)); + else throw new Error("usage: node scripts/check-browser-output-matrix.mjs [--evidence-root=] [--output=]"); +} + +function fail(message) { + throw new Error(`Browser/output matrix: ${message}`); +} + +function repositoryChild(path, label) { + const child = relative(repositoryRoot, path); + if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) { + fail(`${label} escapes the repository`); + } + return child.replaceAll("\\", "/"); +} + +repositoryChild(evidenceRoot, "evidence root"); +repositoryChild(outputPath, "output"); + +function jsonFiles(directory) { + if (!existsSync(directory)) fail(`evidence root does not exist: ${directory}`); + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return jsonFiles(path); + return entry.isFile() && entry.name.endsWith(".json") ? [path] : []; + }); +} + +function canonicalInstant(value, label) { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value ?? "")) { + fail(`${label} is not a canonical UTC instant`); + } + return Date.parse(value); +} + +function same(actual, expected, label) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(`${label} drifted`); +} + +function gitObject(arguments_, label) { + const result = spawnSync("git", arguments_, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + }); + if (result.error || result.status !== 0) fail(`cannot resolve checkout ${label}`); + const value = result.stdout.trim(); + if (!/^[0-9a-f]{40}$/u.test(value)) fail(`checkout ${label} is invalid`); + return value; +} + +function verifyArtifact(evidencePath, artifact, label) { + if (!artifact || typeof artifact.path !== "string" || !/^[0-9a-f]{64}$/u.test(artifact.sha256)) { + fail(`${label} metadata is invalid`); + } + const path = resolve(dirname(evidencePath), artifact.path); + const child = relative(dirname(evidencePath), path); + if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) { + fail(`${label} escapes its host artifact`); + } + const bytes = readFileSync(path); + if (sha256(bytes) !== artifact.sha256 || bytes.byteLength !== artifact.bytes) { + fail(`${label} hash or size drifted`); + } +} + +const authoritySha256 = sha256(readFileSync(authorityPath)); +const documents = jsonFiles(evidenceRoot) + .map((path) => ({ path, value: JSON.parse(readFileSync(path, "utf8")) })) + .filter(({ value }) => value.kind === "pliegocss-browser-output-host-evidence"); +if (documents.length !== authority.hosts.length) { + fail(`expected ${authority.hosts.length} host documents, found ${documents.length}`); +} + +const source = documents[0]?.value.source; +const inputs = documents[0]?.value.inputs; +const checkoutSource = { + commit: gitObject(["rev-parse", "HEAD"], "commit"), + gitTree: gitObject(["rev-parse", "HEAD^{tree}"], "Git tree"), +}; +if (source?.commit !== checkoutSource.commit || source?.gitTree !== checkoutSource.gitTree) { + fail("host evidence is not bound to the aggregator checkout"); +} +const hostSummaries = []; +for (const expectedHost of authority.hosts) { + const entry = documents.find(({ value }) => value.host?.id === expectedHost.id); + if (!entry) fail(`missing ${expectedHost.id}`); + const document = entry.value; + if (document.schemaVersion !== 1 || document.result !== "passed") fail(`${expectedHost.id} did not pass`); + same(document.source, source, `${expectedHost.id} source`); + if (document.source.dirty || document.source.statusEntryCount !== 0) { + fail(`${expectedHost.id} is not clean-tree evidence`); + } + if (document.source.statusSha256 !== sha256(Buffer.alloc(0))) { + fail(`${expectedHost.id} clean status hash drifted`); + } + if (!/^[0-9a-f]{40}$/u.test(document.source.commit) || !/^[0-9a-f]{40}$/u.test(document.source.gitTree)) { + fail(`${expectedHost.id} source IDs are invalid`); + } + const generated = canonicalInstant(document.generatedAtUtc, `${expectedHost.id}.generatedAtUtc`); + const expires = canonicalInstant(document.expiresAtUtc, `${expectedHost.id}.expiresAtUtc`); + if (expires - generated !== authority.maximumEvidenceAgeHours * 60 * 60 * 1_000) { + fail(`${expectedHost.id} validity window drifted`); + } + if (Date.now() > expires) fail(`${expectedHost.id} evidence expired`); + same( + { + id: document.host.id, + runner: document.host.runner, + os: document.host.os, + arch: document.host.arch, + browser: document.host.browser, + }, + expectedHost, + `${expectedHost.id} host identity`, + ); + if (!document.host.browserVersion || document.host.playwright !== "1.61.1") { + fail(`${expectedHost.id} browser/tooling identity is incomplete`); + } + if ( + typeof document.compiler?.rustc !== "string" || + typeof document.compiler?.host !== "string" || + !/^[0-9a-f]{64}$/u.test(document.compiler?.sha256 ?? "") || + Object.hasOwn(document.compiler ?? {}, "executable") + ) { + fail(`${expectedHost.id} compiler identity is incomplete or non-portable`); + } + if ( + document.authority?.path !== "benchmarks/browser-output-certification-v1/authority.json" || + document.authority?.sha256 !== authoritySha256 + ) { + fail(`${expectedHost.id} authority drifted`); + } + same(document.resetModes, authority.resetModes, `${expectedHost.id} reset modes`); + same(document.computedProperties, authority.computedProperties, `${expectedHost.id} computed properties`); + same(document.coverage, authority.requiredCoverage, `${expectedHost.id} coverage`); + same(document.inputs, inputs, `${expectedHost.id} inputs`); + if ( + JSON.stringify(Object.keys(document.inputs ?? {}).sort()) !== JSON.stringify([ + "competitorAuthoritySha256", + "competitorExpiresAtUtc", + "competitorObservedAtUtc", + "fixtureSha256", + "pliegoCssSha256", + "resetSha256", + "tailwindCliManifestSha256", + "tailwindCliVersion", + "tailwindCssSha256", + "tailwindPackageManifestSha256", + "tailwindVersion", + ]) || + document.inputs.competitorAuthoritySha256 !== sha256(competitorBytes) || + document.inputs.competitorObservedAtUtc !== competitor.observedAtUtc || + document.inputs.competitorExpiresAtUtc !== competitor.expiresAtUtc || + document.inputs?.tailwindVersion !== lane.version || + document.inputs?.tailwindCliVersion !== lane.cliVersion || + document.inputs.tailwindPackageManifestSha256 !== tailwindManifestSha256 || + document.inputs.tailwindCliManifestSha256 !== tailwindCliManifestSha256 || + document.inputs.fixtureSha256 !== authority.fixture.sha256 || + document.inputs.resetSha256 !== authority.reset.sha256 || + !/^[0-9a-f]{64}$/u.test(document.inputs.pliegoCssSha256 ?? "") || + !/^[0-9a-f]{64}$/u.test(document.inputs.tailwindCssSha256 ?? "") + ) { + fail(`${expectedHost.id} competitor provenance is incomplete`); + } + const expectedObservationIds = authority.resetModes.flatMap((mode) => + authority.scenarios.map((scenario) => `${mode.id}/${scenario.id}`), + ); + const actualObservationIds = document.observations.map( + (observation) => `${observation.resetMode}/${observation.scenario}`, + ); + same(actualObservationIds, expectedObservationIds, `${expectedHost.id} observations`); + let maximumMismatchRatio = 0; + let maximumLayoutGeometryDeltaCssPx = 0; + for (const observation of document.observations) { + const scenario = authority.scenarios.find((candidate) => candidate.id === observation.scenario); + if (!scenario) fail(`${expectedHost.id}/${observation.scenario} scenario is not authoritative`); + same( + observation.viewport, + { width: scenario.width, height: scenario.height }, + `${expectedHost.id}/${observation.resetMode}/${observation.scenario} viewport`, + ); + if (observation.action !== scenario.action) { + fail(`${expectedHost.id}/${observation.resetMode}/${observation.scenario} action drifted`); + } + if (!observation.computed.equal || observation.computed.differenceCount !== 0) { + fail(`${expectedHost.id}/${observation.resetMode}/${observation.scenario} computed styles differ`); + } + if ( + !Array.isArray(observation.computed.differences) || + observation.computed.differences.length !== 0 || + !/^[0-9a-f]{64}$/u.test(observation.computed.pliegoSha256 ?? "") || + !/^[0-9a-f]{64}$/u.test(observation.computed.tailwindSha256 ?? "") || + !Number.isSafeInteger(observation.computed.nodeCount) || + observation.computed.nodeCount < 1 + ) { + fail(`${expectedHost.id}/${observation.resetMode}/${observation.scenario} computed evidence is incomplete`); + } + const geometryDelta = observation.computed.maximumLayoutGeometryDeltaCssPx; + if ( + !Number.isFinite(geometryDelta) || + geometryDelta < 0 || + geometryDelta > authority.comparison.maximumLayoutGeometryDeltaCssPx + ) { + fail(`${expectedHost.id}/${observation.resetMode}/${observation.scenario} layout geometry failed`); + } + maximumLayoutGeometryDeltaCssPx = Math.max( + maximumLayoutGeometryDeltaCssPx, + geometryDelta, + ); + const screenshots = observation.screenshots; + if ( + !screenshots.passed || + screenshots.threshold !== authority.comparison.screenshotPixelThreshold || + screenshots.maximumMismatchRatio !== authority.comparison.maximumScreenshotMismatchRatio || + !Number.isSafeInteger(screenshots.mismatchPixels) || + screenshots.mismatchPixels < 0 || + !Number.isFinite(screenshots.mismatchRatio) || + screenshots.mismatchRatio < 0 || + screenshots.mismatchRatio > authority.comparison.maximumScreenshotMismatchRatio || + !Number.isSafeInteger(screenshots.width) || + !Number.isSafeInteger(screenshots.height) || + screenshots.width !== scenario.width || + screenshots.height < scenario.height || + screenshots.tailwindWidth !== screenshots.width || + screenshots.tailwindHeight !== screenshots.height || + screenshots.mismatchPixels > screenshots.width * screenshots.height || + screenshots.mismatchRatio !== + Number((screenshots.mismatchPixels / (screenshots.width * screenshots.height)).toFixed(9)) + ) { + fail(`${expectedHost.id}/${observation.resetMode}/${observation.scenario} screenshot comparison failed`); + } + maximumMismatchRatio = Math.max(maximumMismatchRatio, screenshots.mismatchRatio ?? 1); + verifyArtifact(entry.path, screenshots.pliego, `${expectedHost.id} Pliego screenshot`); + verifyArtifact(entry.path, screenshots.tailwind, `${expectedHost.id} Tailwind screenshot`); + verifyArtifact(entry.path, screenshots.diff, `${expectedHost.id} screenshot diff`); + } + if ( + document.summary.computedMismatchCount !== 0 || + document.summary.screenshotMismatchCount !== 0 || + document.summary.resetModeCount !== authority.resetModes.length || + document.summary.scenarioCount !== expectedObservationIds.length || + document.summary.maximumLayoutGeometryDeltaCssPx !== + Number(maximumLayoutGeometryDeltaCssPx.toFixed(6)) + ) { + fail(`${expectedHost.id} summary drifted`); + } + hostSummaries.push({ + id: expectedHost.id, + runner: expectedHost.runner, + os: expectedHost.os, + arch: expectedHost.arch, + browser: expectedHost.browser, + browserVersion: document.host.browserVersion, + generatedAtUtc: document.generatedAtUtc, + expiresAtUtc: document.expiresAtUtc, + scenarios: document.observations.length, + maximumLayoutGeometryDeltaCssPx: Number(maximumLayoutGeometryDeltaCssPx.toFixed(6)), + maximumScreenshotMismatchRatio: Number(maximumMismatchRatio.toFixed(9)), + evidence: { + path: repositoryChild(entry.path, `${expectedHost.id} evidence`), + sha256: sha256(readFileSync(entry.path)), + }, + }); +} + +const matrix = { + schemaVersion: 1, + kind: "pliegocss-browser-output-certification-matrix", + result: "passed", + generatedAtUtc: new Date().toISOString().replace(/\.\d{3}Z$/u, "Z"), + source: { + commit: source.commit, + gitTree: source.gitTree, + }, + authority: { + path: "benchmarks/browser-output-certification-v1/authority.json", + sha256: authoritySha256, + }, + coverage: authority.requiredCoverage, + resetModes: authority.resetModes.map((mode) => mode.id), + hosts: hostSummaries, + claimBoundary: authority.comparison.claimBoundary, +}; +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(matrix, null, 2)}\n`); +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + result: "passed", + source: matrix.source, + hosts: matrix.hosts.length, + browsers: authority.browsers, + output: repositoryChild(outputPath, "output"), + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-browser-output-matrix.test.mjs b/scripts/check-browser-output-matrix.test.mjs new file mode 100644 index 0000000..90ed248 --- /dev/null +++ b/scripts/check-browser-output-matrix.test.mjs @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const authorityPath = resolve( + root, + "benchmarks", + "browser-output-certification-v1", + "authority.json", +); +const authorityBytes = readFileSync(authorityPath); +const authority = JSON.parse(authorityBytes); +const competitor = JSON.parse(readFileSync(resolve(root, authority.competitorAuthority), "utf8")); +const competitorBytes = readFileSync(resolve(root, authority.competitorAuthority)); +const lane = competitor.lanes.find((candidate) => candidate.id === authority.competitorLane); +assert(lane); + +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const workRoot = resolve( + root, + "target", + "browser-output-certification", + `matrix-contract-${process.pid}`, +); +const incoming = resolve(workRoot, "incoming"); +const output = resolve(workRoot, "matrix.json"); +const generated = new Date(); +generated.setMilliseconds(0); +const expires = new Date(generated.getTime() + authority.maximumEvidenceAgeHours * 60 * 60 * 1_000); +const source = { + commit: spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).stdout.trim(), + gitTree: spawnSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: root, encoding: "utf8" }).stdout.trim(), + dirty: false, + statusEntryCount: 0, + statusSha256: sha256(Buffer.alloc(0)), +}; + +function runMatrix() { + return spawnSync( + process.execPath, + [ + "scripts/check-browser-output-matrix.mjs", + `--evidence-root=${relative(root, incoming).replaceAll("\\", "/")}`, + `--output=${relative(root, output).replaceAll("\\", "/")}`, + ], + { cwd: root, encoding: "utf8", windowsHide: true }, + ); +} + +function evidence(host, directory) { + const artifacts = {}; + for (const [name, bytes] of [ + ["pliego", Buffer.from("pliego")], + ["tailwind", Buffer.from("tailwind")], + ["diff", Buffer.from("diff")], + ]) { + const path = resolve(directory, `${name}.png`); + writeFileSync(path, bytes); + artifacts[name] = { path: `${name}.png`, sha256: sha256(bytes), bytes: bytes.byteLength }; + } + const observations = authority.resetModes.flatMap((mode) => + authority.scenarios.map((scenario) => ({ + resetMode: mode.id, + scenario: scenario.id, + viewport: { width: scenario.width, height: scenario.height }, + action: scenario.action, + computed: { + equal: true, + differenceCount: 0, + differences: [], + maximumLayoutGeometryDeltaCssPx: 0, + pliegoSha256: "3".repeat(64), + tailwindSha256: "3".repeat(64), + nodeCount: 44, + }, + screenshots: { + passed: true, + byteEqual: true, + width: scenario.width, + height: scenario.height, + tailwindWidth: scenario.width, + tailwindHeight: scenario.height, + mismatchPixels: 0, + mismatchRatio: 0, + diff: artifacts.diff, + threshold: authority.comparison.screenshotPixelThreshold, + maximumMismatchRatio: authority.comparison.maximumScreenshotMismatchRatio, + pliego: artifacts.pliego, + tailwind: artifacts.tailwind, + }, + })), + ); + return { + schemaVersion: 1, + kind: "pliegocss-browser-output-host-evidence", + result: "passed", + generatedAtUtc: generated.toISOString().replace(/\.000Z$/u, "Z"), + expiresAtUtc: expires.toISOString().replace(/\.000Z$/u, "Z"), + source, + host: { + ...host, + os: host.os, + arch: host.arch, + node: process.version, + playwright: "1.61.1", + browserVersion: "contract-test", + }, + authority: { + path: "benchmarks/browser-output-certification-v1/authority.json", + sha256: sha256(authorityBytes), + }, + compiler: { + sha256: "4".repeat(64), + rustc: "rustc 1.96.0 (contract-test)", + host: "contract-test", + }, + inputs: { + competitorAuthoritySha256: sha256(competitorBytes), + competitorObservedAtUtc: competitor.observedAtUtc, + competitorExpiresAtUtc: competitor.expiresAtUtc, + tailwindVersion: lane.version, + tailwindCliVersion: lane.cliVersion, + tailwindPackageManifestSha256: sha256( + readFileSync(resolve(root, "node_modules", lane.packageAlias, "package.json")), + ), + tailwindCliManifestSha256: sha256( + readFileSync(resolve(root, "node_modules", ...lane.cliPackageAlias.split("/"), "package.json")), + ), + fixtureSha256: authority.fixture.sha256, + resetSha256: authority.reset.sha256, + pliegoCssSha256: "5".repeat(64), + tailwindCssSha256: "6".repeat(64), + }, + resetModes: authority.resetModes, + computedProperties: authority.computedProperties, + observations, + coverage: authority.requiredCoverage, + summary: { + resetModeCount: authority.resetModes.length, + scenarioCount: observations.length, + computedMismatchCount: 0, + screenshotMismatchCount: 0, + maximumLayoutGeometryDeltaCssPx: 0, + }, + claimBoundary: authority.comparison.claimBoundary, + }; +} + +try { + mkdirSync(incoming, { recursive: true }); + const paths = []; + for (const host of authority.hosts) { + const directory = resolve(incoming, host.id); + mkdirSync(directory, { recursive: true }); + const path = resolve(directory, "evidence.json"); + writeFileSync(path, `${JSON.stringify(evidence(host, directory), null, 2)}\n`); + paths.push(path); + } + + const passing = runMatrix(); + assert.equal(passing.status, 0, passing.stderr); + const matrix = JSON.parse(readFileSync(output, "utf8")); + assert.equal(matrix.result, "passed"); + assert.equal(matrix.hosts.length, authority.hosts.length); + + for (let index = 0; index < paths.length; index += 1) { + const drifted = evidence(authority.hosts[index], dirname(paths[index])); + drifted.source = { ...drifted.source, commit: "f".repeat(40) }; + writeFileSync(paths[index], `${JSON.stringify(drifted, null, 2)}\n`); + } + const checkoutDrift = runMatrix(); + assert.notEqual(checkoutDrift.status, 0); + assert.match(checkoutDrift.stderr, /not bound to the aggregator checkout/u); + + for (let index = 0; index < paths.length; index += 1) { + writeFileSync( + paths[index], + `${JSON.stringify(evidence(authority.hosts[index], dirname(paths[index])), null, 2)}\n`, + ); + } + const first = JSON.parse(readFileSync(paths[0], "utf8")); + first.source = { ...first.source, dirty: true, statusEntryCount: 1 }; + writeFileSync(paths[0], `${JSON.stringify(first, null, 2)}\n`); + const dirty = runMatrix(); + assert.notEqual(dirty.status, 0); + assert.match(dirty.stderr, /source drifted|not clean-tree evidence/u); + + writeFileSync(paths[0], `${JSON.stringify(evidence(authority.hosts[0], dirname(paths[0])), null, 2)}\n`); + const geometryFailure = JSON.parse(readFileSync(paths[0], "utf8")); + geometryFailure.observations[0].computed.maximumLayoutGeometryDeltaCssPx = + authority.comparison.maximumLayoutGeometryDeltaCssPx + 0.01; + geometryFailure.summary.maximumLayoutGeometryDeltaCssPx = + geometryFailure.observations[0].computed.maximumLayoutGeometryDeltaCssPx; + writeFileSync(paths[0], `${JSON.stringify(geometryFailure, null, 2)}\n`); + const geometry = runMatrix(); + assert.notEqual(geometry.status, 0); + assert.match(geometry.stderr, /layout geometry failed/u); + + writeFileSync(paths[0], `${JSON.stringify(evidence(authority.hosts[0], dirname(paths[0])), null, 2)}\n`); + writeFileSync(resolve(dirname(paths[0]), "diff.png"), "tampered"); + const tampered = runMatrix(); + assert.notEqual(tampered.status, 0); + assert.match(tampered.stderr, /hash or size drifted/u); + + process.stdout.write("browser/output matrix fail-closed contract: pass\n"); +} finally { + rmSync(workRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +} diff --git a/scripts/check-compatibility-policy.mjs b/scripts/check-compatibility-policy.mjs index 9abec45..b7336c5 100644 --- a/scripts/check-compatibility-policy.mjs +++ b/scripts/check-compatibility-policy.mjs @@ -2,11 +2,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node import { dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const targetDir = process.env.CARGO_TARGET_DIR - ? resolve(root, process.env.CARGO_TARGET_DIR) - : join(root, "target"); +const cargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); +const targetDir = cargoTargetRoot(root, cargoEnvironment); const runtime = join(targetDir, "compatibility-policy", `run-${process.pid}-${Date.now()}`); const expected = readFileSync( join(root, "integration-tests", "compatibility-policy", "expected.baseline-widely.json"), @@ -24,7 +27,7 @@ function run(program, args, { cwd = runtime, expectedStatus = 0, timeout = 30_00 const result = spawnSync(program, args, { cwd, encoding: "utf8", - env: { ...process.env, CARGO_TARGET_DIR: targetDir }, + env: cargoEnvironment, timeout, windowsHide: true, }); @@ -45,7 +48,7 @@ function resolveExecutable() { return configured; } - run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { + run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"], { cwd: root, timeout: 300_000, }); diff --git a/scripts/check-distribution-bundle.mjs b/scripts/check-distribution-bundle.mjs new file mode 100644 index 0000000..521b893 --- /dev/null +++ b/scripts/check-distribution-bundle.mjs @@ -0,0 +1,287 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { + ROOT, + digest, + exactKeys, + fail, + fileSha256, + readJson, + repositoryVersion, + safeFilename, + validateCycloneDx, + validatePackageManifest, + validatePolicy, + validateSource, +} from "./repository-distribution.mjs"; + +function parse(args) { + const parsed = {}; + for (const arg of args) { + if (arg === "--") continue; + const match = /^--([a-z-]+)=(.+)$/u.exec(arg); + if (!match || Object.hasOwn(parsed, match[1])) fail(`invalid option: ${arg}`); + parsed[match[1]] = match[2]; + } + return parsed; +} + +function tar(args) { + const result = spawnSync("tar", args, { + cwd: ROOT, + encoding: "utf8", + windowsHide: true, + }); + if (result.error || result.status !== 0) { + fail(`tar ${args.join(" ")} failed: ${result.error?.message ?? result.stderr.trim()}`); + } + return result.stdout; +} + +const options = parse(process.argv.slice(2)); +if (!options.root) fail("--root is required"); +const bundleRoot = resolve(ROOT, options.root); +const version = repositoryVersion(); +const policy = validatePolicy(); +const manifestPath = resolve(bundleRoot, `pliegocss-distribution-${version}.json`); +if (!existsSync(manifestPath)) fail(`distribution manifest is missing: ${manifestPath}`); +const manifest = readJson(manifestPath, "distribution manifest"); +exactKeys( + manifest, + [ + "schemaVersion", + "kind", + "version", + "repository", + "source", + "packageManager", + "immutableReleaseRequired", + "supportedTargets", + "artifacts", + "claimBoundary", + ], + "distribution manifest", +); +if ( + manifest.schemaVersion !== 1 || + manifest.kind !== "pliegocss-repository-distribution" || + manifest.version !== version || + manifest.repository !== policy.repository || + manifest.immutableReleaseRequired !== true || + manifest.claimBoundary !== policy.claimBoundary +) { + fail("distribution manifest identity drifted"); +} +validateSource(manifest.source); +exactKeys( + manifest.packageManager, + ["name", "version", "installSource", "npmjsPublished", "lifecycleScripts"], + "distribution package manager", +); +if ( + manifest.packageManager.name !== "pnpm" || + manifest.packageManager.version !== policy.package.packageManagerVersion || + manifest.packageManager.installSource !== "verified-local-github-release-asset" || + manifest.packageManager.npmjsPublished !== false || + manifest.packageManager.lifecycleScripts !== false +) { + fail("distribution package-manager boundary drifted"); +} +const expectedTargets = policy.targets.map(({ id, os, arch }) => ({ id, os, arch })); +if (JSON.stringify(manifest.supportedTargets) !== JSON.stringify(expectedTargets)) { + fail("supported target inventory drifted"); +} +if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length !== 10) { + fail("distribution must contain three archives, six SBOMs, and one pnpm package"); +} +const expectedArtifacts = new Map(); +for (const target of policy.targets) { + expectedArtifacts.set(`pliegocss-${version}-${target.id}.tar.gz`, { + kind: "native-archive", + target: target.id, + }); + for (const binary of policy.binaries) { + expectedArtifacts.set(`${target.id}-${binary}.cdx.json`, { + kind: "sbom", + target: target.id, + binary, + }); + } +} +expectedArtifacts.set(`pliegocss-pnpm-${version}.tgz`, { + kind: "pnpm-package", + target: "universal-declared-hosts", +}); +const artifactNames = new Set(); +for (const artifact of manifest.artifacts) { + if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) { + fail("distribution artifact must be an object"); + } + safeFilename(artifact.file, "distribution artifact file"); + const expected = expectedArtifacts.get(artifact.file); + if (!expected || artifact.kind !== expected.kind || artifact.target !== expected.target) { + fail(`${artifact.file} type or target drifted`); + } + if (artifact.kind === "native-archive") { + exactKeys( + artifact, + ["kind", "file", "bytes", "sha256", "target", "binaries", "sboms"], + `${artifact.file} manifest entry`, + ); + if ( + !Array.isArray(artifact.binaries) || + !Array.isArray(artifact.sboms) || + artifact.binaries.length !== policy.binaries.length || + artifact.sboms.length !== policy.binaries.length + ) { + fail(`${artifact.file} receipt inventory drifted`); + } + } else if (artifact.kind === "sbom") { + exactKeys( + artifact, + ["kind", "file", "bytes", "sha256", "target", "binary"], + `${artifact.file} manifest entry`, + ); + if (artifact.binary !== expected.binary) fail(`${artifact.file} binary drifted`); + } else { + exactKeys( + artifact, + [ + "kind", + "file", + "bytes", + "sha256", + "target", + "package", + "lifecycleScripts", + "dependencies", + "registryPublication", + ], + `${artifact.file} manifest entry`, + ); + if ( + artifact.package !== policy.package.name || + artifact.lifecycleScripts !== false || + artifact.dependencies !== 0 || + artifact.registryPublication !== false + ) { + fail("pnpm package release boundary drifted"); + } + } + if (artifactNames.has(artifact.file)) fail(`duplicate artifact ${artifact.file}`); + artifactNames.add(artifact.file); + if (!Number.isSafeInteger(artifact.bytes) || artifact.bytes <= 0) { + fail(`${artifact.file}.bytes must be a positive safe integer`); + } + digest(artifact.sha256, `${artifact.file}.sha256`); + const path = resolve(bundleRoot, artifact.file); + if (!existsSync(path) || !statSync(path).isFile()) fail(`artifact missing: ${artifact.file}`); + if (fileSha256(path) !== artifact.sha256 || statSync(path).size !== artifact.bytes) { + fail(`artifact hash or size drifted: ${artifact.file}`); + } + if (artifact.kind === "sbom") { + validateCycloneDx(path, artifact.binary, version); + } +} +const expectedArtifactNames = new Set(expectedArtifacts.keys()); +if ( + artifactNames.size !== expectedArtifactNames.size || + [...expectedArtifactNames].some((file) => !artifactNames.has(file)) +) { + fail("distribution artifact filename inventory drifted"); +} +for (const target of policy.targets) { + const archiveName = `pliegocss-${version}-${target.id}.tar.gz`; + if (!artifactNames.has(archiveName)) fail(`native archive missing for ${target.id}`); + const archiveFiles = tar(["-tzf", resolve(bundleRoot, archiveName)]) + .replaceAll("\\", "/") + .split(/\r?\n/u) + .map((line) => line.replace(/^\.\//u, "")) + .filter((line) => line && !line.endsWith("/")); + const expectedArchiveFiles = new Set([ + ...policy.binaries.map((binary) => `${binary}${target.extension}`), + ...policy.binaries.map((binary) => `${binary}.cdx.json`), + "native.json", + "LICENSE", + ]); + if ( + archiveFiles.length !== expectedArchiveFiles.size || + archiveFiles.some((file) => !expectedArchiveFiles.has(file)) + ) { + fail(`${archiveName} file inventory drifted`); + } +} +const packageName = `pliegocss-pnpm-${version}.tgz`; +if (!artifactNames.has(packageName)) fail("pnpm package artifact is missing"); +const packagePath = resolve(bundleRoot, packageName); +const packageFiles = tar(["-tzf", packagePath]) + .replaceAll("\\", "/") + .split(/\r?\n/u) + .filter((line) => line && !line.endsWith("/")); +const expectedPackageFiles = new Set([ + "package/package.json", + "package/bin/pliego-cssc.mjs", + "package/bin/pliego-css-lsp.mjs", + "package/bin/launcher.mjs", + "package/LICENSE", + "package/README.md", +]); +for (const target of policy.targets) { + for (const binary of policy.binaries) { + expectedPackageFiles.add(`package/vendor/${target.id}/${binary}${target.extension}`); + expectedPackageFiles.add(`package/SBOM/${target.id}-${binary}.cdx.json`); + } +} +if ( + packageFiles.length !== expectedPackageFiles.size || + packageFiles.some((file) => !expectedPackageFiles.has(file)) +) { + fail("pnpm package file inventory drifted"); +} +const packedManifest = JSON.parse(tar(["-xOzf", packagePath, "package/package.json"])); +validatePackageManifest(packedManifest, policy, { packed: true }); + +const checksumsPath = resolve(bundleRoot, "SHA256SUMS"); +if (!existsSync(checksumsPath)) fail("SHA256SUMS is missing"); +const checksumLines = readFileSync(checksumsPath, "utf8").trimEnd().split(/\r?\n/u); +const checksums = new Map(); +for (const line of checksumLines) { + const match = /^([0-9a-f]{64}) ([A-Za-z0-9@._-]+)$/u.exec(line); + if (!match || checksums.has(match[2])) fail(`invalid checksum line: ${line}`); + checksums.set(match[2], match[1]); +} +const expectedChecksums = new Set([...artifactNames, basename(manifestPath)]); +if ( + checksums.size !== expectedChecksums.size || + [...expectedChecksums].some((file) => !checksums.has(file)) +) { + fail("SHA256SUMS inventory drifted"); +} +for (const [file, expected] of checksums) { + if (fileSha256(resolve(bundleRoot, file)) !== expected) fail(`checksum mismatch for ${file}`); +} +const allowedFiles = new Set([...expectedChecksums, "SHA256SUMS"]); +const actualFiles = readdirSync(bundleRoot).filter((file) => statSync(resolve(bundleRoot, file)).isFile()); +if (actualFiles.some((file) => !allowedFiles.has(file)) || actualFiles.length !== allowedFiles.size) { + fail("release asset directory contains an undeclared file"); +} + +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + result: "passed", + version, + source: manifest.source, + targets: policy.targets.length, + nativeArchives: 3, + sboms: 6, + pnpmPackage: packageName, + npmjsPublished: false, + lifecycleScripts: false, + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-document-authority.mjs b/scripts/check-document-authority.mjs new file mode 100644 index 0000000..84e4f60 --- /dev/null +++ b/scripts/check-document-authority.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const fail = (message) => { throw new Error(`document authority: ${message}`); }; +const authority = JSON.parse( + readFileSync(resolve(root, "docs/product/document-authority.json"), "utf8"), +); +const exact = (value, keys, role) => { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${role} must be an object`); + if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...keys].sort())) { + fail(`${role} fields drifted`); + } +}; +exact( + authority, + ["schemaVersion", "kind", "currentState", "siteProjection", "normative", "historical", "rules"], + "root", +); +if (authority.schemaVersion !== 1 || authority.kind !== "pliegocss-document-authority") { + fail("header drifted"); +} +exact(authority.currentState, ["machine", "human", "generatedBy"], "currentState"); +exact(authority.siteProjection, ["source", "route"], "siteProjection"); +const requiredCurrent = { + machine: "docs/product/release-readiness-0.1.0.json", + human: "docs/product/release-readiness-0.1.0.md", + generatedBy: "scripts/render-release-readiness.mjs", +}; +if (JSON.stringify(authority.currentState) !== JSON.stringify(requiredCurrent)) { + fail("current state is not singular and canonical"); +} +if ( + authority.siteProjection.source !== "docs/site/release-readiness.md" || + authority.siteProjection.route !== "/docs/release-readiness/" +) { + fail("site projection drifted"); +} +for (const path of [ + ...Object.values(authority.currentState), + authority.siteProjection.source, + ...authority.normative, +]) { + if (!existsSync(resolve(root, path))) fail(`missing governed document ${path}`); +} +for (const entry of authority.historical) { + exact(entry, ["path", "supersededBy"], `historical.${entry.path}`); + if (!entry.path.startsWith("docs/archive/")) fail(`${entry.path} is not archived`); + if (!existsSync(resolve(root, entry.path))) fail(`missing archive ${entry.path}`); + if (!existsSync(resolve(root, entry.supersededBy))) fail(`missing successor ${entry.supersededBy}`); + const bytes = readFileSync(resolve(root, entry.path), "utf8"); + if (!bytes.includes("historical") && !bytes.includes("archiveStatus")) { + fail(`${entry.path} lacks an archive boundary`); + } +} +for (const stale of [ + "docs/product/hardening-report-0.1.0-rc.2.md", + "docs/product/next-gap-2026-07-20.md", + "docs/product/next-gap-2026-07-20.json", +]) { + if (existsSync(resolve(root, stale))) fail(`stale current-looking path still exists: ${stale}`); +} +const readinessFiles = readdirSync(resolve(root, "docs/product")) + .filter((name) => /^release-readiness-.+\.json$/u.test(name)); +if (JSON.stringify(readinessFiles) !== JSON.stringify(["release-readiness-0.1.0.json"])) { + fail("more than one current readiness machine document exists"); +} +const render = spawnSync( + process.execPath, + [resolve(root, authority.currentState.generatedBy), "--check"], + { cwd: root, encoding: "utf8", windowsHide: true }, +); +if (render.error || render.status !== 0) { + fail(`generated current page drifted: ${render.error?.message ?? render.stderr.trim()}`); +} +process.stdout.write( + `${JSON.stringify({schemaVersion: 1, currentState: authority.currentState.machine, historical: authority.historical.length, siteRoute: authority.siteProjection.route}, null, 2)}\n`, +); diff --git a/scripts/check-engine-boundary.mjs b/scripts/check-engine-boundary.mjs new file mode 100644 index 0000000..488ba52 --- /dev/null +++ b/scripts/check-engine-boundary.mjs @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const read = (path) => readFileSync(resolve(root, path), "utf8"); + +const compiler = read("crates/pliego-css-compiler/src/engine.rs"); +const compilerLib = read("crates/pliego-css-compiler/src/lib.rs"); +const cli = read("crates/pliego-cssc/src/main.rs"); +const scanner = read("crates/pliego-cssc/src/source_candidates.rs"); +const macros = read("crates/pliego-css-macros/src/lib.rs"); +const lsp = read("crates/pliego-css-lsp/src/lib.rs"); + +function requireText(source, fragment, label) { + if (!source.includes(fragment)) throw new Error(`${label} is missing ${fragment}`); +} + +function forbidText(source, fragment, label) { + if (source.includes(fragment)) throw new Error(`${label} still contains ${fragment}`); +} + +for (const contract of [ + "pub struct CompileRequest", + "pub struct CompileResult", + "pub struct AnalysisHost", + "pub struct PhysicalRulePlanner", + "pub struct PcxRequest", + "pub fn compile(&mut self, request: &CompileRequest)", +]) { + requireText(compiler, contract, "shared compiler engine"); +} +for (const exported of [ + "AnalysisHost", + "CompileRequest", + "CompileResult", + "PhysicalRulePlanner", + "PcxRequest", +]) { + requireText(compilerLib, exported, "engine export"); +} + +for (const [source, label] of [ + [cli, "CLI/watch adapter"], + [scanner, "Rust source scanner"], + [macros, "procedural macro"], + [lsp, "LSP adapter"], +]) { + requireText(source, "AnalysisHost", label); +} +requireText(cli, "CompileRequest", "CLI/watch adapter"); +requireText(scanner, "PcxRequest", "Rust source scanner"); +requireText(macros, "PcxRequest", "procedural macro"); +requireText(lsp, "PcxRequest", "LSP adapter"); +requireText(cli, "engine: AnalysisHost", "watch cache"); + +forbidText(cli, "CssFragmentCache", "CLI physical planner"); +forbidText(scanner, "analyze_cross_clause_conflicts", "Rust source scanner"); +forbidText(macros, "fn cartesian_indices", "procedural macro"); +forbidText(macros, "analyze_cross_clause_conflicts", "procedural macro"); +forbidText(lsp, "Command::", "LSP adapter"); +forbidText(lsp, "std::process::{", "LSP adapter"); +forbidText(lsp, "TemporarySource", "LSP adapter"); +forbidText(lsp, "run_source_check", "LSP adapter"); + +process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + engineApi: "CompileRequest->CompileResult", + sharedPcxFrontend: ["macros", "scanner", "lsp"], + sharedAdapters: ["cli", "watch", "lsp"], + physicalPlanner: "compiler-owned", + lspCompilerProcesses: 0, + }, + null, + 2, + )}\n`, +); diff --git a/scripts/check-external-adoption.mjs b/scripts/check-external-adoption.mjs new file mode 100644 index 0000000..3315814 --- /dev/null +++ b/scripts/check-external-adoption.mjs @@ -0,0 +1,30 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadExternalAdoptionBundle, + validateExternalAdoptionBundle, +} from "./external-adoption-v1.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const allowed = new Set(["--validate", "--require-promotion-ready"]); +const args = process.argv.slice(2); +if (args.some((arg) => !allowed.has(arg))) { + process.stderr.write( + "usage: node scripts/check-external-adoption.mjs [--validate] [--require-promotion-ready]\n", + ); + process.exit(2); +} + +try { + const status = validateExternalAdoptionBundle(loadExternalAdoptionBundle(root), { root }); + process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + if (args.includes("--require-promotion-ready") && status.result !== "ready") { + process.stderr.write( + `external adoption is blocked: ${status.missingCoverage.join(", ")}\n`, + ); + process.exitCode = 1; + } +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; +} diff --git a/scripts/check-getting-started.mjs b/scripts/check-getting-started.mjs index 0533fd7..5c9c50e 100644 --- a/scripts/check-getting-started.mjs +++ b/scripts/check-getting-started.mjs @@ -1,15 +1,16 @@ import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { isAbsolute, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const ROOT = resolve(import.meta.dirname, ".."); -const TARGET_ROOT = process.env.CARGO_TARGET_DIR - ? isAbsolute(process.env.CARGO_TARGET_DIR) - ? process.env.CARGO_TARGET_DIR - : resolve(ROOT, process.env.CARGO_TARGET_DIR) - : join(ROOT, "target"); +const cargoEnvironment = isolatedCargoEnvironment(ROOT, { + env: process.env, + toolchain: "1.85.0", +}); +const TARGET_ROOT = cargoTargetRoot(ROOT, cargoEnvironment); const EXECUTABLE_SUFFIX = process.platform === "win32" ? ".exe" : ""; const BASIC_EXECUTABLE = join(TARGET_ROOT, "debug", `pliego-css-basic-example${EXECUTABLE_SUFFIX}`); const CLI_EXECUTABLE = join(TARGET_ROOT, "debug", `pliego-cssc${EXECUTABLE_SUFFIX}`); @@ -24,6 +25,7 @@ function fail(message, detail = undefined) { function run(command, args) { const result = spawnSync(command, args, { cwd: ROOT, + env: cargoEnvironment, encoding: "utf8", windowsHide: true, }); diff --git a/scripts/check-hosted-browser-matrix.mjs b/scripts/check-hosted-browser-matrix.mjs index 4127cd1..89b781d 100644 --- a/scripts/check-hosted-browser-matrix.mjs +++ b/scripts/check-hosted-browser-matrix.mjs @@ -1,18 +1,37 @@ -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { AuthorityError, validateBrowserMatrix } from "./release-authority.mjs"; const root = resolve(import.meta.dirname, ".."); -const matrix = JSON.parse(readFileSync(resolve(root, "docs/benchmarks/hosted-browser-matrix.json"), "utf8")); -const currentOs = process.platform === "win32" ? "windows" : process.platform; -function fail(message) { throw new Error(`hosted browser matrix: ${message}`); } -if (matrix.schemaVersion !== 1 || matrix.hosts?.length !== 5) fail("header or host inventory drifted"); -const allowed = new Set(["available", "not-configured", "passed", "failed"]); -for (const host of matrix.hosts) { - if (!allowed.has(host.status)) fail(`invalid status for ${host.id}`); - if (host.status === "passed" && host.automation !== "passed") fail(`${host.id} claims pass without automation`); - if (host.status === "available" && (!host.executable || !["executable-discovery-only", "local-computed-style-smoke"].includes(host.claim))) fail(`${host.id} availability is not bounded`); - if (host.status === "not-configured" && host.claim !== "none") fail(`${host.id} claims evidence while not configured`); - if (host.executable && currentOs === host.os && !existsSync(resolve(host.executable))) fail(`${host.id} executable is absent: ${host.executable}`); +const args = process.argv.slice(2); +const enforceRelease = + (args.length === 1 && args[0] === "--profile=release") || + (args.length === 2 && args[0] === "--profile" && args[1] === "release"); +if ( + !enforceRelease && + !( + args.length === 0 || + (args.length === 1 && args[0] === "--validate") + ) +) { + throw new Error(`hosted browser matrix: unknown mode ${args.join(" ")}`); +} + +try { + const value = JSON.parse( + readFileSync(resolve(root, "docs/benchmarks/hosted-browser-matrix.json"), "utf8"), + ); + const summary = validateBrowserMatrix(value, { root }); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (enforceRelease && summary.result !== "ready") { + process.stderr.write( + `hosted browser matrix: release blocked by ${summary.missingRequiredHosts.join(", ")}\n`, + ); + process.exitCode = 1; + } +} catch (error) { + if (error instanceof AuthorityError) { + throw new Error(`hosted browser matrix: ${error.message}`); + } + throw error; } -if (matrix.historicalEvidence?.status !== "historical-manual" || matrix.historicalEvidence.ciReplay !== "not-configured") fail("historical evidence boundary drifted"); -process.stdout.write(`${JSON.stringify({schemaVersion:1,available:matrix.hosts.filter((host)=>host.status==='available').map((host)=>host.id),passed:matrix.hosts.filter((host)=>host.status==='passed').map((host)=>host.id),notConfigured:matrix.hosts.filter((host)=>host.status==='not-configured').map((host)=>host.id)},null,2)}\n`); diff --git a/scripts/check-installed-repository-package.mjs b/scripts/check-installed-repository-package.mjs new file mode 100644 index 0000000..1240f2d --- /dev/null +++ b/scripts/check-installed-repository-package.mjs @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, isAbsolute, relative, resolve } from "node:path"; +import { ROOT, fail, repositoryVersion } from "./repository-distribution.mjs"; + +function parse(args) { + const parsed = {}; + for (const arg of args) { + if (arg === "--") continue; + const match = /^--([a-z-]+)=(.+)$/u.exec(arg); + if (!match || Object.hasOwn(parsed, match[1])) fail(`invalid option: ${arg}`); + parsed[match[1]] = match[2]; + } + return parsed; +} + +function run(command, args, cwd, { input } = {}) { + const executable = process.platform === "win32" && command === "pnpm" + ? process.env.ComSpec ?? "cmd.exe" + : command; + const commandArgs = process.platform === "win32" && command === "pnpm" + ? ["/d", "/s", "/c", command, ...args] + : args; + const result = spawnSync(executable, commandArgs, { + cwd, + encoding: "utf8", + input, + timeout: 30_000, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + fail( + `${command} ${args.join(" ")} failed: ${result.error?.message ?? `${result.stdout}${result.stderr}`.trim()}`, + ); + } + return `${result.stdout}${result.stderr}`.trim(); +} + +const options = parse(process.argv.slice(2)); +if (!options.tarball) fail("--tarball is required"); +const tarball = resolve(ROOT, options.tarball); +const version = repositoryVersion(); +const temporaryRoot = mkdtempSync(resolve(tmpdir(), "pliegocss-pnpm-consumer-")); +try { + writeFileSync( + resolve(temporaryRoot, "package.json"), + `${JSON.stringify( + { + name: "pliegocss-repository-distribution-consumer", + version: "0.0.0", + private: true, + packageManager: "pnpm@11.7.0", + }, + null, + 2, + )}\n`, + ); + writeFileSync( + resolve(temporaryRoot, "pnpm-workspace.yaml"), + "packages: []\nblockExoticSubdeps: true\nonlyBuiltDependencies: []\n", + ); + run( + "pnpm", + ["add", "--save-dev", "--save-exact", "--offline", "--ignore-scripts", tarball], + temporaryRoot, + ); + const packageManifest = JSON.parse(readFileSync(resolve(temporaryRoot, "package.json"), "utf8")); + const installed = packageManifest.devDependencies?.["@pliegocss/cli"]; + if (typeof installed !== "string" || !installed.startsWith("file:")) { + fail("consumer did not record the verified local release asset"); + } + const cliVersion = run("pnpm", ["exec", "pliego-cssc", "--version"], temporaryRoot); + const lspVersion = run("pnpm", ["exec", "pliego-css-lsp", "--version"], temporaryRoot); + if (!cliVersion.includes(version) || !lspVersion.includes(version)) { + fail(`installed binaries do not report ${version}`); + } + process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + result: "passed", + host: `${process.platform}:${process.arch}`, + tarball: basename(tarball), + version, + install: "pnpm-local-verified-asset", + scripts: "ignored-and-absent", + }, + null, + 2, + )}\n`, + ); +} finally { + const local = relative(resolve(tmpdir()), temporaryRoot); + if (!local || local.startsWith("..") || isAbsolute(local) || !basename(temporaryRoot).startsWith("pliegocss-pnpm-consumer-")) { + fail(`refusing to remove unsafe consumer path: ${temporaryRoot}`); + } + rmSync(temporaryRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +} diff --git a/scripts/check-lsp-integration.mjs b/scripts/check-lsp-integration.mjs index c8f7e92..c6396a3 100644 --- a/scripts/check-lsp-integration.mjs +++ b/scripts/check-lsp-integration.mjs @@ -3,15 +3,17 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const ROOT = resolve(import.meta.dirname, ".."); const executable = process.platform === "win32" ? ".exe" : ""; -const target = process.env.CARGO_TARGET_DIR - ? resolve(ROOT, process.env.CARGO_TARGET_DIR) - : resolve(ROOT, "target"); +const cargoEnvironment = isolatedCargoEnvironment(ROOT, { + env: process.env, + toolchain: "1.85.0", +}); +const target = cargoTargetRoot(ROOT, cargoEnvironment); const lsp = resolve(target, "debug", `pliego-css-lsp${executable}`); const compiler = resolve(target, "debug", `pliego-cssc${executable}`); -const cancellationProxy = resolve(target, `lsp-cancellation-proxy${executable}`); const diagnosticCorpus = JSON.parse( readFileSync(resolve(ROOT, "integration-tests", "lsp-diagnostics", "corpus.json"), "utf8"), ); @@ -22,25 +24,12 @@ function fail(message) { const build = spawnSync( "cargo", - ["+1.85", "build", "--locked", "-p", "pliego-css-lsp", "-p", "pliego-cssc"], - { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ["+1.85.0", "build", "--locked", "-p", "pliego-css-lsp", "-p", "pliego-cssc"], + { cwd: ROOT, env: cargoEnvironment, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); if (build.error) fail(`cannot build LSP gate: ${build.error.message}`); if (build.status !== 0) fail(`${build.stdout}${build.stderr}`.trim()); if (!existsSync(lsp) || !existsSync(compiler)) fail("LSP gate binaries are missing"); -const proxyBuild = spawnSync( - "rustc", - [ - "+1.85", - resolve(ROOT, "integration-tests", "lsp-diagnostics", "cancellation-proxy.rs"), - "-o", - cancellationProxy, - ], - { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, -); -if (proxyBuild.error) fail(`cannot build cancellation proxy: ${proxyBuild.error.message}`); -if (proxyBuild.status !== 0) fail(`${proxyBuild.stdout}${proxyBuild.stderr}`.trim()); -if (!existsSync(cancellationProxy)) fail("LSP cancellation proxy is missing"); if ( diagnosticCorpus.kind !== "pliegocss-lsp-diagnostic-corpus" || diagnosticCorpus.schemaVersion !== 2 || @@ -113,7 +102,7 @@ const index = { physicalCoverage: "compiler-verified-complete", styleIdFormatVersion: 2, classNameFormatVersion: 1, - themeIdFormatVersion: 2, + themeIdFormatVersion: 3, themeId: "seed", targets: "modern", format: "minified", @@ -198,7 +187,10 @@ for (const item of diagnosticCorpus.cases) { stdio: ["ignore", "pipe", "pipe"], }); if (checked.error || checked.status === 0) { - fail(`CLI diagnostic corpus case ${item.id} did not fail as required`); + fail( + `CLI diagnostic corpus case ${item.id} did not fail as required ` + + `(status=${checked.status}, error=${checked.error?.message ?? "none"}, stdout=${checked.stdout}, stderr=${checked.stderr})`, + ); } const document = JSON.parse(checked.stderr); if (document.schemaVersion !== 1 || document.diagnostics?.length !== 1) { @@ -219,15 +211,17 @@ for (const item of diagnosticCorpus.cases) { const child = spawn( lsp, - ["--pliego-cssc", cancellationProxy, "--seed", "--project-index", "out/pliego.index.json"], + [ + "--pliego-cssc", + resolve(workspace, `must-not-execute-pliego-cssc${executable}`), + "--seed", + "--project-index", + "out/pliego.index.json", + ], { cwd: workspace, stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - PLIEGOCSS_REAL_COMPILER: compiler, - PLIEGOCSS_CANCELLATION_MARKER: resolve(workspace, "cancellation.pid"), - }, + env: process.env, }, ); const output = []; @@ -250,25 +244,6 @@ async function waitForOutput(fragment, offset = 0, timeoutMs = 5000) { fail(`LSP did not emit ${JSON.stringify(fragment)} within ${timeoutMs} ms`); } -async function waitForFile(path, timeoutMs = 5000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (existsSync(path)) return; - await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); - } - fail(`LSP cancellation child did not create ${path} within ${timeoutMs} ms`); -} - -function processIsAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (error?.code === "ESRCH") return false; - throw error; - } -} - const uri = pathToFileURL(resolve(workspace, "src", "view.rs")).href; const rootUri = pathToFileURL(workspace).href; const cursor = text.indexOf("gap") + 3; @@ -331,7 +306,6 @@ send({ params: { textDocument: { uri }, options: { tabSize: 4, insertSpaces: true } }, }); await waitForOutput("unknown utility `unknown-thing`"); -const cancellationMarker = resolve(workspace, "cancellation.pid"); const cancellationVersion = 11; send({ jsonrpc: "2.0", @@ -341,11 +315,6 @@ send({ contentChanges: [{ text: 'fn view(){let _=pc!("cancel-me");}' }], }, }); -await waitForFile(cancellationMarker); -const cancelledPid = Number(readFileSync(cancellationMarker, "utf8").trim()); -if (!Number.isSafeInteger(cancelledPid) || cancelledPid <= 0) { - fail("cancellation proxy wrote an invalid process id"); -} const recoveryVersion = 12; const recoveryText = 'fn view(){let _=pc!("unknown-after-cancel");}'; const recoveryOffset = Buffer.concat(output).length; @@ -360,11 +329,6 @@ send({ }); await waitForOutput("unknown utility `unknown-after-cancel`", recoveryOffset, 5000); const cancellationLatencyMs = Date.now() - cancellationStarted; -const processDeadline = Date.now() + 2000; -while (Date.now() < processDeadline && processIsAlive(cancelledPid)) { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); -} -if (processIsAlive(cancelledPid)) fail("stale semantic compiler child remained alive"); const pcxVersion = 13; const pcxText = @@ -408,19 +372,7 @@ for (const item of diagnosticCorpus.cases) { corpusRuns.push({ item, sourceText, version: corpusVersion }); corpusVersion += 1; } -const toolFailureVersion = corpusVersion; -const toolFailureOffset = Buffer.concat(output).length; -send({ - jsonrpc: "2.0", - method: "textDocument/didChange", - params: { - textDocument: { uri, version: toolFailureVersion }, - contentChanges: [{ text: 'fn view(){let _=pc!("proxy-invalid-json");}' }], - }, -}); -await waitForOutput('"code":"PCL001"', toolFailureOffset); - -const literalLimitVersion = toolFailureVersion + 1; +const literalLimitVersion = corpusVersion; const literalLimitText = `fn view(){${'let _=pc!("flex");'.repeat(257)}}`; const literalLimitOffset = Buffer.concat(output).length; send({ @@ -542,19 +494,17 @@ if ( const cancelledVersionMessages = published.filter( (message) => message.params?.version === cancellationVersion && - message.params?.diagnostics?.some((diagnostic) => diagnostic.code === "PCL001"), + message.params?.diagnostics?.some((diagnostic) => diagnostic.code === "PCS001"), ); if (cancelledVersionMessages.length !== 0) { - fail("cancelled semantic process leaked a tooling diagnostic"); + fail("superseded in-process semantic analysis leaked a stale diagnostic"); } const recoveryMessages = published.filter( (message) => message.params?.version === recoveryVersion && message.params?.diagnostics?.some((diagnostic) => diagnostic.code === "PCS001"), ); -if (recoveryMessages.length !== 1) { - fail("semantic worker did not recover after forceful cancellation"); -} +if (recoveryMessages.length !== 1) fail("semantic worker did not publish the current version"); for (const run of corpusRuns) { const diagnostics = published .filter((message) => message.params?.version === run.version) @@ -591,17 +541,6 @@ for (const run of corpusRuns) { fail(`LSP diagnostic corpus range ${run.item.id} drifted`); } } -const toolFailureDiagnostics = published - .filter((message) => message.params?.version === toolFailureVersion) - .flatMap((message) => message.params?.diagnostics ?? []); -const toolFailure = toolFailureDiagnostics.find((diagnostic) => diagnostic.code === "PCL001"); -if ( - toolFailure?.severity !== 1 || - toolFailure?.data?.category !== "tool" || - !toolFailure.message.includes("invalid pliego-cssc diagnostic JSON") -) { - fail("PCL001 fault-injection contract drifted"); -} const literalLimitDiagnostics = published .filter((message) => message.params?.version === literalLimitVersion) .flatMap((message) => message.params?.diagnostics ?? []); @@ -657,9 +596,10 @@ process.stdout.write( semanticVersion: semanticMessages[0].params.version, staleSemanticResults: 0, protocolResponsiveDuringDebounce: true, - forcefulCancellation: true, - cancelledCompilerPid: cancelledPid, - cancellationRecoveryMs: cancellationLatencyMs, + inProcessAnalysis: true, + compilerProcesses: 0, + supersededAnalysisSuppressed: true, + currentVersionLatencyMs: cancellationLatencyMs, pcxDiagnostic: pcxDiagnostic.code, pcxVersion: pcxMessages[0].params.version, pcxRange: pcxDiagnostic.range, @@ -667,7 +607,7 @@ process.stdout.write( diagnosticCorpusCases: corpusRuns.length, diagnosticCorpusEquality: "code-message-range-severity-suggestion-replacement", typedDiagnosticCodes: [...expectedCorpusCodes, "PCX003"], - operationalFallbacks: [toolFailure.code, literalLimit.code], + operationalGuards: [literalLimit.code], formattingEdits: byId.get(2).result.length, completion: item.label, completionRange: item.textEdit.range, diff --git a/scripts/check-manifest-graph.mjs b/scripts/check-manifest-graph.mjs index 26ad069..a2b554d 100644 --- a/scripts/check-manifest-graph.mjs +++ b/scripts/check-manifest-graph.mjs @@ -13,16 +13,25 @@ import { import { dirname, join, resolve } from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const fixture = join(root, "integration-tests", "manifest-graph"); const runtime = join(root, "target", "manifest-graph", `run-${process.pid}-${Date.now()}`); const project = join(runtime, "project"); -const targetDir = join(root, "target"); +const cargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); +const targetDir = cargoTargetRoot(root, cargoEnvironment); const expectedCss = readFileSync(join(fixture, "expected.css")); const expectedManifest = readFileSync(join(fixture, "expected.manifest.json")); const reachabilityName = "pliego.reachability.json"; const updateGoldens = process.env.PLIEGOCSS_UPDATE_GOLDENS === "1"; +const cargoBuildTimeout = Number.parseInt( + process.env.PLIEGOCSS_CARGO_BUILD_TIMEOUT_MS ?? "600000", + 10, +); function fail(message) { throw new Error(message); @@ -46,7 +55,7 @@ function run(program, args, { cwd = root, expectedStatus = 0, timeout = 30_000 } const result = spawnSync(program, args, { cwd, encoding: "utf8", - env: { ...process.env, CARGO_TARGET_DIR: targetDir }, + env: cargoEnvironment, timeout, windowsHide: true, }); @@ -67,8 +76,8 @@ function resolveExecutable() { return configured; } - run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { - timeout: 120_000, + run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"], { + timeout: cargoBuildTimeout, }); const executable = join( targetDir, @@ -251,7 +260,7 @@ async function assertWatchContract(executable) { ], { cwd: project, - env: { ...process.env, CARGO_TARGET_DIR: targetDir }, + env: cargoEnvironment, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }, @@ -368,7 +377,7 @@ function assertManifestIntegrity(manifest, css, schemaVersion) { assert(manifest.schemaVersion === schemaVersion, `expected manifest schema ${schemaVersion}`); assert(manifest.styleIdFormatVersion === 2, "StyleId format version drifted"); assert(manifest.classNameFormatVersion === 1, "class-name format version drifted"); - assert(manifest.themeIdFormatVersion === 2, "theme ID format version drifted"); + assert(manifest.themeIdFormatVersion === 3, "theme ID format version drifted"); assert(manifest.targets === "modern", "target contract drifted"); assert(manifest.format === "minified", "format contract drifted"); assert(manifest.cssBytes === css.byteLength, "manifest CSS byte length is stale"); diff --git a/scripts/check-maturity-map.mjs b/scripts/check-maturity-map.mjs index a74d6ad..38bb63b 100644 --- a/scripts/check-maturity-map.mjs +++ b/scripts/check-maturity-map.mjs @@ -49,7 +49,7 @@ for (const [index, capability] of document.capabilities.entries()) { if (!existsSync(resolve(root, owner))) fail(`${label}.owners references missing ${owner}`); } } -const expected = { stable: 6, beta: 12, experimental: 3 }; +const expected = { stable: 6, beta: 14, experimental: 3 }; for (const maturity of maturities) { if (counts[maturity] !== expected[maturity]) { fail(`expected ${expected[maturity]} ${maturity} capabilities, found ${counts[maturity]}`); diff --git a/scripts/check-migration-corpus.mjs b/scripts/check-migration-corpus.mjs index c80c1eb..a7ea0e9 100644 --- a/scripts/check-migration-corpus.mjs +++ b/scripts/check-migration-corpus.mjs @@ -3,10 +3,13 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const corpusRoot = join(root, "integration-tests", "migration-corpus"); const manifest = JSON.parse(readFileSync(join(corpusRoot, "cases.json"), "utf8")); +const cargoEnvironment = isolatedCargoEnvironment(root); +const cargoTarget = cargoTargetRoot(root, cargoEnvironment); function fail(message) { throw new Error(message); @@ -48,10 +51,13 @@ if (!Array.isArray(manifest.cases) || manifest.cases.length < 3) { } const cargo = process.env.CARGO ?? "cargo"; -run(cargo, ["build", "--locked", "--quiet", "-p", "pliego-cssc"], { cwd: root }); +run(cargo, ["build", "--locked", "--quiet", "-p", "pliego-cssc"], { + cwd: root, + env: cargoEnvironment, +}); const executable = process.env.PLIEGOCSS_BIN ?? - join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); + join(cargoTarget, "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); if (!existsSync(executable)) fail(`missing PliegoCSS CLI at ${executable}`); const ids = new Set(); diff --git a/scripts/check-migration-real-corpus.mjs b/scripts/check-migration-real-corpus.mjs index f642355..95bb811 100644 --- a/scripts/check-migration-real-corpus.mjs +++ b/scripts/check-migration-real-corpus.mjs @@ -11,8 +11,11 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cargoEnvironment = isolatedCargoEnvironment(root); +const cargoTarget = cargoTargetRoot(root, cargoEnvironment); const manifestPath = join(root, "integration-tests", "migration-real-corpus", "projects.json"); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); @@ -130,11 +133,14 @@ if (process.env.PLIEGOCSS_RUN_NETWORK_CORPUS !== "1") { const cargo = process.env.CARGO ?? "cargo"; if (!process.env.PLIEGOCSS_BIN) { - run(cargo, ["build", "--locked", "--quiet", "-p", "pliego-cssc"], { cwd: root }); + run(cargo, ["build", "--locked", "--quiet", "-p", "pliego-cssc"], { + cwd: root, + env: cargoEnvironment, + }); } const executable = process.env.PLIEGOCSS_BIN ?? - join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); + join(cargoTarget, "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); if (!existsSync(executable)) fail(`missing PliegoCSS CLI at ${executable}`); const temporaryBase = join(root, "target"); diff --git a/scripts/check-neovim-client.mjs b/scripts/check-neovim-client.mjs index e7024ac..e24d163 100644 --- a/scripts/check-neovim-client.mjs +++ b/scripts/check-neovim-client.mjs @@ -10,6 +10,7 @@ import { writeFileSync, } from "node:fs"; import { basename, relative, resolve, sep } from "node:path"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const ROOT = resolve(import.meta.dirname, ".."); const EDITOR = resolve(ROOT, "editors", "neovim"); @@ -95,14 +96,15 @@ if (!existsSync(executable)) { } if (!existsSync(executable)) fail(`Neovim executable is missing after extraction: ${executable}`); -const target = process.env.CARGO_TARGET_DIR - ? resolve(ROOT, process.env.CARGO_TARGET_DIR) - : resolve(ROOT, "target"); +const cargoEnvironment = isolatedCargoEnvironment(ROOT, { + env: process.env, + toolchain: "1.85.0", +}); +const target = cargoTargetRoot(ROOT, cargoEnvironment); const workspace = resolve(target, "lsp-integration-workspace"); const output = resolve(target, "neovim-host-result.json"); const binarySuffix = process.platform === "win32" ? ".exe" : ""; const lsp = resolve(target, "debug", `pliego-css-lsp${binarySuffix}`); -const compiler = resolve(target, "debug", `pliego-cssc${binarySuffix}`); for (const path of [workspace, output]) { if (!path.startsWith(`${target}${sep}`)) fail(`unsafe fixture path: ${path}`); } @@ -112,7 +114,7 @@ const fixture = spawnSync( { cwd: ROOT, encoding: "utf8", - env: { ...process.env, PLIEGOCSS_KEEP_LSP_FIXTURE: "1" }, + env: { ...cargoEnvironment, PLIEGOCSS_KEEP_LSP_FIXTURE: "1" }, }, ); if (fixture.error || fixture.status !== 0) { @@ -133,7 +135,6 @@ try { PLIEGOCSS_NEOVIM_WORKSPACE: workspace, PLIEGOCSS_NEOVIM_OUTPUT: output, PLIEGOCSS_NEOVIM_LSP: lsp, - PLIEGOCSS_NEOVIM_COMPILER: compiler, }, timeout: 60_000, }, diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs index a842f56..74d2527 100644 --- a/scripts/check-packages.mjs +++ b/scripts/check-packages.mjs @@ -13,6 +13,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { isolatedCargoEnvironment } from "./rust-target.mjs"; const ROOT = resolve(import.meta.dirname, ".."); const EXPECTED_ORDER = [ @@ -51,6 +52,7 @@ const PUBLIC_API_FIXTURE_FILES = [ "src/main.rs", ]; const PACKAGE_TOOLCHAIN = "1.96.0"; +const MSRV_TOOLCHAIN = "1.85.0"; const EXPECTED_REPOSITORY = "https://github.com/celiumsai/pliegocss"; const EXPECTED_HOMEPAGE = "https://pliegocss.dev"; const EXPECTED_REGISTRY = "crates-io"; @@ -58,14 +60,23 @@ const EXPECTED_MSRV = "1.85"; const MAX_COMPRESSED_ARCHIVE_BYTES = 72 * 1024; const EXPECTED_LICENSE_SHA256 = "8ada45cd9f843acf64e4722ae262c622a2b3b3007c7310ef36ac1061a30f6adb"; +const packageCargoEnvironment = isolatedCargoEnvironment(ROOT, { + env: process.env, + toolchain: PACKAGE_TOOLCHAIN, +}); +const msrvCargoEnvironment = isolatedCargoEnvironment(ROOT, { + env: process.env, + toolchain: MSRV_TOOLCHAIN, +}); function fail(message) { throw new Error(message); } -function run(command, args, { capture = false, cwd = ROOT } = {}) { +function run(command, args, { capture = false, cwd = ROOT, env = process.env } = {}) { const result = spawnSync(command, args, { cwd, + env, encoding: "utf8", stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", }); @@ -85,7 +96,10 @@ function run(command, args, { capture = false, cwd = ROOT } = {}) { } function runPackageCargo(args, options = {}) { - return run("cargo", [`+${PACKAGE_TOOLCHAIN}`, ...args], options); + return run("cargo", [`+${PACKAGE_TOOLCHAIN}`, ...args], { + ...options, + env: packageCargoEnvironment, + }); } function dependencyContract(dependency) { @@ -616,13 +630,14 @@ try { run("cargo", ["+1.85.0", "generate-lockfile"], { cwd: downstreamRoot, capture: true, + env: msrvCargoEnvironment, }); const downstreamMetadata = JSON.parse( run( "cargo", ["+1.85.0", "metadata", "--format-version", "1", "--all-features", "--locked"], - { cwd: downstreamRoot, capture: true }, + { cwd: downstreamRoot, capture: true, env: msrvCargoEnvironment }, ), ); if (canonical(downstreamMetadata.workspace_root) !== canonical(downstreamRoot)) { @@ -766,12 +781,12 @@ try { run( "cargo", ["+1.85.0", "check", "--release", "--all-targets", "--all-features", "--locked"], - { cwd: downstreamRoot }, + { cwd: downstreamRoot, env: msrvCargoEnvironment }, ); const dtcgDownstreamRun = run( "cargo", ["+1.85.0", "run", "--release", "--quiet", "--locked"], - { cwd: downstreamRoot, capture: true }, + { cwd: downstreamRoot, capture: true, env: msrvCargoEnvironment }, ).trim(); if (!/^[0-9a-f]{32}\tpc_[0-9a-z]+$/u.test(dtcgDownstreamRun)) { fail( @@ -783,7 +798,7 @@ try { const tomlDownstreamRun = run( "cargo", ["+1.85.0", "run", "--release", "--quiet", "--locked"], - { cwd: downstreamRoot, capture: true }, + { cwd: downstreamRoot, capture: true, env: msrvCargoEnvironment }, ).trim(); if (!/^[0-9a-f]{32}\tpc_[0-9a-z]+$/u.test(tomlDownstreamRun)) { fail( diff --git a/scripts/check-parallel-determinism.mjs b/scripts/check-parallel-determinism.mjs index 2a8cfcb..1610b7c 100644 --- a/scripts/check-parallel-determinism.mjs +++ b/scripts/check-parallel-determinism.mjs @@ -11,8 +11,15 @@ import { } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const rustToolchain = process.env.PLIEGOCSS_RUST_TOOLCHAIN ?? "1.85.0"; +const cargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: rustToolchain, +}); +const cargoTarget = cargoTargetRoot(root, cargoEnvironment); const runtime = join(root, "target", "parallel-determinism", `run-${process.pid}-${Date.now()}`); const project = join(runtime, "project"); const workers = 8; @@ -42,7 +49,7 @@ function run(program, args, options = {}) { const result = spawnSync(program, args, { cwd: root, encoding: "utf8", - env: { ...process.env }, + env: cargoEnvironment, maxBuffer: 16 * 1024 * 1024, timeout: 180_000, windowsHide: true, @@ -62,7 +69,7 @@ function runAsync(program, args, options = {}) { return new Promise((resolvePromise, reject) => { const child = spawn(program, args, { cwd: project, - env: { ...process.env }, + env: cargoEnvironment, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...options, @@ -97,10 +104,9 @@ function resolveExecutable() { assert(existsSync(configured), `PLIEGO_CSSC does not exist: ${configured}`); return configured; } - run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"]); + run("cargo", [`+${rustToolchain}`, "build", "--locked", "-p", "pliego-cssc"]); const executable = join( - root, - "target", + cargoTarget, "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", ); @@ -115,17 +121,14 @@ function resolveLockHolder() { return configured; } run("cargo", [ - "+1.85.0", + `+${rustToolchain}`, "build", "--locked", "--manifest-path", "integration-tests/publication-lock-holder/Cargo.toml", - "--target-dir", - "target", ]); const executable = join( - root, - "target", + cargoTarget, "debug", process.platform === "win32" ? "pliego-publication-lock-holder.exe" @@ -462,6 +465,7 @@ async function main() { JSON.stringify( { schemaVersion: 1, + rustToolchain, workersPerCohort: workers, maximumConcurrentCompilerProcesses: workers, referenceProcesses: profiles.length, diff --git a/scripts/check-physical-trace.mjs b/scripts/check-physical-trace.mjs index d7f80d5..89cd875 100644 --- a/scripts/check-physical-trace.mjs +++ b/scripts/check-physical-trace.mjs @@ -12,16 +12,25 @@ import { } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const fixture = join(root, "integration-tests", "physical-trace"); const runtime = join(root, "target", "physical-trace", `run-${process.pid}-${Date.now()}`); const project = join(runtime, "project"); -const targetDir = join(root, "target"); +const cargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); +const targetDir = cargoTargetRoot(root, cargoEnvironment); const reachabilityName = "pliego.reachability.json"; const expectedCss = readFileSync(join(fixture, "expected.css")); const expectedManifest = readFileSync(join(fixture, "expected.manifest.json")); const updateGoldens = process.env.PLIEGOCSS_UPDATE_GOLDENS === "1"; +const cargoBuildTimeout = Number.parseInt( + process.env.PLIEGOCSS_CARGO_BUILD_TIMEOUT_MS ?? "600000", + 10, +); const physicalEdgeKinds = new Set([ "declarationContributesToPhysicalDeclaration", @@ -80,7 +89,7 @@ function run(program, args, { cwd = root, expectedStatus = 0, timeout = 30_000 } const result = spawnSync(program, args, { cwd, encoding: "utf8", - env: { ...process.env, CARGO_TARGET_DIR: targetDir }, + env: cargoEnvironment, maxBuffer: 16 * 1024 * 1024, timeout, windowsHide: true, @@ -102,8 +111,8 @@ function resolveExecutable() { return configured; } - run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { - timeout: 180_000, + run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"], { + timeout: cargoBuildTimeout, }); const executable = join( targetDir, @@ -295,7 +304,7 @@ async function assertWatchContract(executable) { ], { cwd: project, - env: { ...process.env, CARGO_TARGET_DIR: targetDir }, + env: cargoEnvironment, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }, @@ -448,7 +457,7 @@ function assertManifest(manifest, css, schemaVersion, targets, format) { assert(manifest.schemaVersion === schemaVersion, `expected manifest schema ${schemaVersion}`); assert(manifest.styleIdFormatVersion === 2, "StyleId format version drifted"); assert(manifest.classNameFormatVersion === 1, "class-name format version drifted"); - assert(manifest.themeIdFormatVersion === 2, "theme ID format version drifted"); + assert(manifest.themeIdFormatVersion === 3, "theme ID format version drifted"); assert(/^[0-9a-f]{32}$/.test(manifest.themeId), "ThemeId is not fixed-width hex"); assert(manifest.targets === targets, `expected target contract ${targets}`); assert(manifest.format === format, `expected CSS format ${format}`); diff --git a/scripts/check-plain-html-integration.mjs b/scripts/check-plain-html-integration.mjs index 93d3535..f13e326 100644 --- a/scripts/check-plain-html-integration.mjs +++ b/scripts/check-plain-html-integration.mjs @@ -12,8 +12,13 @@ import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { basename, extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const cargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); const fixtureRoot = join(root, "integration-tests", "plain-html-smoke"); const adapterPath = join(fixtureRoot, "adapter.json"); @@ -140,7 +145,7 @@ function runCompiler(adapter, outputRoot) { "--manifest-version", "3", ], - { cwd: root, encoding: "utf8", windowsHide: true }, + { cwd: root, env: cargoEnvironment, encoding: "utf8", windowsHide: true }, ); if (result.error) { throw result.error; diff --git a/scripts/check-pliegors-browser.mjs b/scripts/check-pliegors-browser.mjs index b4ee77a..ec5cd3b 100644 --- a/scripts/check-pliegors-browser.mjs +++ b/scripts/check-pliegors-browser.mjs @@ -1,14 +1,16 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, + writeFileSync, } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; -import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -34,12 +36,41 @@ const expected = Object.freeze({ const skipBuild = process.env.PLIEGOCSS_SKIP_PLIEGORS_BUILD === "1"; const keepSite = process.env.PLIEGOCSS_KEEP_PLIEGORS_SITE === "1"; const agentReport = process.env.PLIEGOCSS_AGENT_REPORT === "1"; +const evidencePath = process.env.PLIEGOCSS_PLIEGORS_BROWSER_EVIDENCE + ? resolve(root, process.env.PLIEGOCSS_PLIEGORS_BROWSER_EVIDENCE) + : null; function fail(message, details = undefined) { const suffix = details === undefined ? "" : `\n${JSON.stringify(details, null, 2)}`; throw new Error(`${message}${suffix}`); } +function gitOutput(arguments_) { + const result = spawnSync("git", arguments_, { + cwd: root, + encoding: "utf8", + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail(`git ${arguments_.join(" ")} failed`, { + exitCode: result.status, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return result.stdout; +} + +function pliegocssSourceIdentity() { + const status = gitOutput(["status", "--porcelain=v1", "-z", "--untracked-files=all"]); + return { + commit: gitOutput(["rev-parse", "HEAD"]).trim(), + gitTree: gitOutput(["rev-parse", "HEAD^{tree}"]).trim(), + dirty: status.length !== 0, + }; +} + function sleep(milliseconds) { return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); } @@ -614,6 +645,10 @@ try { const report = { schema: "pliegocss/pliegors-browser-gate/2", passed, + pliegocssSource: pliegocssSourceIdentity(), + pliegorsContract: JSON.parse( + readFileSync(join(root, "integration-tests", "pliegors-smoke", "pliegors-contract.json"), "utf8"), + ), browser: { executable: basename(chromePath), jsVersion: browserVersion.jsVersion, @@ -629,6 +664,20 @@ try { serverErrors: site.errors, serverRequests, }; + if (evidencePath) { + const child = relative(root, evidencePath); + if ( + !child || + child === ".." || + child.startsWith(`..${sep}`) || + isAbsolute(child) || + !evidencePath.endsWith(".json") + ) { + fail("PLIEGOCSS_PLIEGORS_BROWSER_EVIDENCE must be a JSON path inside the repository"); + } + mkdirSync(dirname(evidencePath), { recursive: true }); + writeFileSync(evidencePath, `${JSON.stringify(report, null, 2)}\n`); + } const compactReport = { schemaVersion: "1.0.0", passed, diff --git a/scripts/check-pliegors-dev-loop.mjs b/scripts/check-pliegors-dev-loop.mjs index 2f3fe44..8bcb40c 100644 --- a/scripts/check-pliegors-dev-loop.mjs +++ b/scripts/check-pliegors-dev-loop.mjs @@ -168,6 +168,19 @@ function materializeFixture(runtime) { join(cargoOverrideDirectory, "config.toml"), `# Generated by the PliegoCSS cross-repository development-loop gate.\npaths = ${JSON.stringify(cargoOverridePaths)}\n`, ); + manifest = manifest + .replace( + 'pliego-dom = "=0.0.2"', + `pliego-dom = { version = "=0.4.0-beta.1", path = ${JSON.stringify(normalizedPath(join(PLIEGORS_ROOT, "crates", "pliego-dom")))} }`, + ) + .replace( + 'pliego-ssg = "=0.0.2"', + `pliego-ssg = { version = "=0.4.0-beta.1", path = ${JSON.stringify(normalizedPath(join(PLIEGORS_ROOT, "crates", "pliego-ssg")))} }`, + ); + writeFileSync(manifestPath, manifest); + run("cargo", [`+${TOOLCHAIN}`, "generate-lockfile", "--manifest-path", manifestPath], { + cwd: project, + }); const driver = join(runtime, "driver.styles.txt"); writeFileSync(driver, "block\n"); return { @@ -306,6 +319,11 @@ async function fetchText(url) { return response.text(); } +async function fetchResponseText(url) { + const response = await fetch(url, { cache: "no-store" }); + return { status: response.status, text: await response.text() }; +} + async function fetchBytes(url) { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) fail(`${url} returned HTTP ${response.status}`); @@ -318,7 +336,8 @@ function attribute(tag, name) { function htmlState(html) { const tag = html.match(/]*>/gu)?.find((candidate) => candidate.includes('id="style-probe"')); - const generation = Number(/\/_pliego\/reload\?since=(\d+)/u.exec(html)?.[1]); + const reload = /]*data-pliego-reload[^>]*>[\s\S]*?<\/script>/u.exec(html)?.[0]; + const generation = Number(/\bconnect\((\d+)\)\}\)\(\)<\/script>/u.exec(reload ?? "")?.[1]); if (!tag || !Number.isSafeInteger(generation)) { fail("development HTML is missing the style probe or reload generation"); } @@ -345,7 +364,9 @@ async function pageState(url, cssPath) { before.className !== after.className || before.generation !== after.generation ) { - fail("PliegoRS page changed while capturing one HTML/CSS state"); + fail( + `PliegoRS page changed while capturing one HTML/CSS state: ${JSON.stringify({ before, after })}`, + ); } const css = new TextDecoder().decode(cssBytes); const cssSha256 = sha256(cssBytes); @@ -450,7 +471,10 @@ async function waitForReload(url, since, processes) { fail(`PliegoRS reload endpoint returned HTTP ${response.status}`); } const body = await response.text(); - const generation = Number(/^data: (\d+)$/mu.exec(body)?.[1]); + const payload = /^data: (.+)$/mu.exec(body)?.[1]; + if (!payload) return null; + const update = JSON.parse(payload); + const generation = typeof update === "number" ? update : update.generation; return Number.isSafeInteger(generation) && generation > since ? generation : null; } finally { clearTimeout(timer); @@ -523,41 +547,57 @@ async function proveInvalidRetainsLastValid(context) { writeFileSync(context.paths.source, sourceFor("invalid")); await Promise.all([ waitForLog(context.cssWatch, watchOffset, "compile failed; keeping the last valid artifact"), - waitForLog(context.pliegoDev, devOffset, "PLIEGO dev: rebuild failed"), + waitForLog(context.pliegoDev, devOffset, "PLIEGO[PLG-BLD-001] dev rebuild failed"), ]); await delay(QUIET_WINDOW_MS); - const after = await pageState(context.url, context.paths.css); + const diagnostic = await fetchResponseText(context.url); + if ( + diagnostic.status !== 500 || + !diagnostic.text.includes("PLG-BLD-001") || + !diagnostic.text.includes(`connect(${before.generation + 1})`) + ) { + fail("invalid edit did not publish the bounded PliegoRS build diagnostic"); + } if ( - !samePageState(after, before) || fileSha256(context.paths.css) !== cssDigest || fileSha256(context.paths.manifest) !== manifestDigest || statSync(context.paths.css).mtimeMs !== cssModified || statSync(context.paths.manifest).mtimeMs !== manifestModified ) { - fail("invalid edit changed the last valid publication group, page, or reload generation"); + fail("invalid edit changed the last valid PliegoCSS publication group"); } return { - generation: after.generation, - retainedProbe: after.probe, - retainedClassName: after.className, + generation: before.generation + 1, + retainedProbe: before.probe, + retainedClassName: before.className, cssSha256: cssDigest, manifestSha256: manifestDigest, publicationMtimePreserved: true, + diagnosticStatus: diagnostic.status, quietWindowMs: QUIET_WINDOW_MS, }; } async function restoreLastValid(context, probe = "p-6") { const processes = [context.cssWatch, context.pliegoDev]; - const before = await stablePageState(context, processes); + const diagnostic = await fetchResponseText(context.url); + if (diagnostic.status !== 500) { + fail("valid restoration requires the preceding PliegoRS build diagnostic"); + } + const generationBefore = Number( + /\bconnect\((\d+)\)\}\)\(\)<\/script>/u.exec(diagnostic.text)?.[1], + ); + if (!Number.isSafeInteger(generationBefore)) { + fail("PliegoRS build diagnostic is missing its reload generation"); + } const devOffset = context.pliegoDev.output.length; - const reload = waitForReload(context.url, before.generation, processes); + const reload = waitForReload(context.url, generationBefore, processes); writeFileSync(context.paths.source, sourceFor(probe)); await waitForLog(context.pliegoDev, devOffset, "PLIEGO dev: rebuilt"); const sseGeneration = await reload; - if (sseGeneration !== before.generation + 1) { + if (sseGeneration !== generationBefore + 1) { fail( - `valid restoration advanced SSE from ${before.generation} to ${sseGeneration}; exactly one rebuild is required`, + `valid restoration advanced SSE from ${generationBefore} to ${sseGeneration}; exactly one rebuild is required`, ); } const after = await waitUntil( @@ -571,7 +611,7 @@ async function restoreLastValid(context, probe = "p-6") { ); await assertPageStateFor(context, after, RELOAD_SETTLE_MS, processes); return { - generationBefore: before.generation, + generationBefore, generationAfter: after.generation, sseGeneration, probe, @@ -822,12 +862,12 @@ function assertGenerationChain(samples, invalid, restored, equivalent) { } const lastGeneration = samples.at(-1)?.generationAfter; if ( - invalid.generation !== lastGeneration || + invalid.generation !== lastGeneration + 1 || restored.generationBefore !== invalid.generation || equivalent.generation !== restored.generationAfter ) { fail( - `reload generation advanced outside an accepted build: ${JSON.stringify({ lastGeneration, invalid: invalid.generation, restored, equivalent: equivalent.generation })}`, + `reload generation drifted outside the expected valid/failure/restoration chain: ${JSON.stringify({ lastGeneration, invalid: invalid.generation, restored, equivalent: equivalent.generation })}`, ); } } diff --git a/scripts/check-pliegors-guide.mjs b/scripts/check-pliegors-guide.mjs index 4575386..a63b146 100644 --- a/scripts/check-pliegors-guide.mjs +++ b/scripts/check-pliegors-guide.mjs @@ -27,7 +27,8 @@ for (const section of requiredSections) { const requiredClaims = [ "product_component!", "ProductRegistry", - "ApplicationTopology", + "ProductTopology", + "to_topology_json", "pliego build", "pliego css check", "pliego.build.json", @@ -46,7 +47,7 @@ for (const claim of requiredClaims) { const fixtureContracts = new Map([ ["integration-tests/pliegors-smoke/src/product.rs", ["ProductRegistry", "application_registry"]], ["integration-tests/pliegors-smoke/src/styles/home.rs", ["product_component!", "pc!"]], - ["integration-tests/pliegors-smoke/src/css_adapter.rs", ["ApplicationTopology", "generate_css_inputs"]], + ["integration-tests/pliegors-smoke/src/css_adapter.rs", ["ProductTopology", "to_topology_json", "generate_css_inputs"]], ["integration-tests/pliegors-smoke/src/bin/collector.rs", ["application_registry"]], ["integration-tests/pliegors-smoke/src/bin/ssg.rs", ["preload_stylesheet", "Site::new"]], ]); diff --git a/scripts/check-pliegors-integration.mjs b/scripts/check-pliegors-integration.mjs index 341ec07..88a2ea5 100644 --- a/scripts/check-pliegors-integration.mjs +++ b/scripts/check-pliegors-integration.mjs @@ -19,6 +19,7 @@ import { resolve, } from "node:path"; import { fileURLToPath } from "node:url"; +import { isolatedCargoEnvironment } from "./rust-target.mjs"; import { gzipSync } from "node:zlib"; import { verifyPliegorsContract } from "./pliegors-contract.mjs"; @@ -124,16 +125,17 @@ mkdirSync(generatedRoot); mkdirSync(bundlesPath); mkdirSync(allCompiledBundlesPath); -const environment = { - ...process.env, +const targetBaseEnvironment = process.env.PLIEGOCSS_CARGO_TARGET_DIR + ? { ...process.env, CARGO_TARGET_DIR: resolve(process.env.PLIEGOCSS_CARGO_TARGET_DIR) } + : process.env; +const environment = isolatedCargoEnvironment(root, { + env: targetBaseEnvironment, + toolchain: "1.86.0", // Keep native executables below the workspace target root. Windows Application // Control policies commonly trust Cargo's project target but reject binaries // from disposable benchmark directories. WSL callers can opt into a native // Linux target directory without weakening the default Windows boundary. - CARGO_TARGET_DIR: process.env.PLIEGOCSS_CARGO_TARGET_DIR - ? resolve(process.env.PLIEGOCSS_CARGO_TARGET_DIR) - : join(root, "target"), -}; +}); // `pliego build` deliberately rejects environment-level compiler/profile overrides. The harness // keeps only CARGO_TARGET_DIR as the explicit generated-output location and strips inherited // overrides before any Cargo child is launched. @@ -524,7 +526,7 @@ if ( manifest.schemaVersion !== 3 || manifest.styleIdFormatVersion !== 2 || manifest.classNameFormatVersion !== 1 || - manifest.themeIdFormatVersion !== 2 || + manifest.themeIdFormatVersion !== 3 || manifest.cssSha256 !== cssSha256 || manifest.cssBytes !== Buffer.byteLength(css) ) { @@ -668,7 +670,7 @@ for (const bundle of bundles) { bundle.manifestDocument.schemaVersion !== 5 || bundle.manifestDocument.styleIdFormatVersion !== 2 || bundle.manifestDocument.classNameFormatVersion !== 1 || - bundle.manifestDocument.themeIdFormatVersion !== 2 || + bundle.manifestDocument.themeIdFormatVersion !== 3 || bundle.manifestDocument.targets !== "modern" || bundle.manifestDocument.format !== "minified" || bundle.manifestDocument.cssSha256 !== digest || diff --git a/scripts/check-portability.mjs b/scripts/check-portability.mjs index 716bcf1..8cfa876 100644 --- a/scripts/check-portability.mjs +++ b/scripts/check-portability.mjs @@ -39,96 +39,96 @@ const foreignCwd = join(runtime, "foreign-cwd"); const sourceName = "café.rs"; const logicalSource = `src/${sourceName}`; const expectedCssSha256 = - "acdb0bfee613ddb3fdae0feb9a8e5118dc5f74d385ed53eb610ff4eefd05d454"; + "65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc"; const expectedManifestSha256 = - "8c1dc690c2cbb8102b6935a970e60938bbda7e50a0c0bf766c5c800305cce598"; + "453f708c9c4edbd388eda1ee9c883212cd5d31421077d123cb1d32bc0b28d4bb"; const expectedControlHashes = { "pliego.css.findings.json": - "a353b52f386f0b896db3c354f45bc0b304dffab1477904a5ee37cd8768be468a", + "79e4817107cbb7a20b5c0be1a8a881b11ebfc3f9e94f83a93f9d318f74059cf9", "pliego.css.manifest.json": - "b8513ecd80e9fe566778aa0bfb0b142ecfb06a014bb34a1caa02c1ad28567fd3", + "711c183edb566062d3feec8e7a7e42117a8b6b6b0d05d237afc0469abd09d78b", "pliego.css.receipt.json": - "81777f9533bb6cd203f68e0f052e5974d09a2d773d7adf8f062f9361100a1c28", + "1162d5743e2ccd7d03614605a700cd55099bde9380c19df5d95d9cfb65875479", }; const expectedBundleControlHashes = { - "app.css": "acdb0bfee613ddb3fdae0feb9a8e5118dc5f74d385ed53eb610ff4eefd05d454", + "app.css": "65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc", "app.css.map": "39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf", "app.manifest.json": - "4328616cd9a2a9e57add1cd6b3aaa735cc8a2d7aa2721c8a2e591ecc0b2bba2c", + "0b17997d04739e7698300d9d13278ecd537b2821e7e123db00f83e4a80d3d609", "pliego.assets.json": - "f24ee1ccb288a3e9243d686caa4f0cc1e83f6d12021a7715cee9cd5eff0dce66", + "9664a30c61b226d6f81fc771c0042206e9a98a64a77429d7e05498b78f194d89", "pliego.css.findings.json": - "2fcc03aff9f36f09095ef046019b8fbfdd2d9af658adf259132a2c761c557cc5", + "9e9d204e6513ad8dbe099348aab3cc259df732667b2ed804059a153288263424", "pliego.css.manifest.json": - "4ac39f4ec4035e4cdf337f900f23eea44b8a52abf951750b3d02e4bd4bd1d76a", + "cf7e29c1a78cc63120b53a0b88b7c9395edd51f9322a0c6c2b565a8a03458475", "pliego.css.receipt.json": - "5776c810e022e391505b3efd441b9f3b0219dab32af6d4bc6f5dc4222c8a3ac0", + "da835752a54b00ec3d7922ec1394958c4c1465c728c86f21f6474540242e1d7c", "pliego.index.json": - "4338ee784241b023b9bbb2b4959187d6f9dddaa75c7c6cdf021ec60346bc1393", + "b685bbaaa08bfac33f9024f96a8a6c8338771ed4d019fe7c01db602ea40a1864", "pliego.tokens.json": - "0a45f1d6e48280ee7b357e2e4d58d82bfc27be715c9fd3f3385098608edd4e42", + "3298873e66ade310459593530c86419c05a973d2404b71882c2d0ae430030a28", }; const expectedAssetPlanControlHashes = { "pliego.css.findings.json": - "3faa43be937eb688dddeb8c72be3a7e3257fc34a0643c4a5b6de51844e03b0ef", + "e036dcb3587507db07e39a55cca5404dd074b4ede3abb70e487005e18ab478b7", "pliego.css.manifest.json": - "cf15c3f7fc738c1058eaa2471cfb942e62969b7aed9e5cdad599ab7522394488", + "a4e31d7f69c2ed88447eaf984c0bfdf6e85180a2ea80990234a51cb5141cc9ba", "pliego.css.receipt.json": - "d76be0703b140e636ebcc2dacec079e8c5cf893139a317e7429bdce4d3b92cea", + "43d2297a39dd74dacd71a49ffee43febee1ba9e83d80bf0a55e1435eb9944fb8", }; const expectedCompileControlHashes = { - "app.css": "acdb0bfee613ddb3fdae0feb9a8e5118dc5f74d385ed53eb610ff4eefd05d454", + "app.css": "65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc", "app.css.map": "39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf", "app.manifest.json": - "8c1dc690c2cbb8102b6935a970e60938bbda7e50a0c0bf766c5c800305cce598", + "453f708c9c4edbd388eda1ee9c883212cd5d31421077d123cb1d32bc0b28d4bb", "pliego.css.findings.json": - "6e75872fa833f550501f433a7be89bf1b90cf97bf52dc130169851a0c9ea2c52", + "3410804862bc781042b42b954127848606c5c3710be43e4b8b774b5acdd7e71c", "pliego.css.manifest.json": - "9a98a8a78cb9f2597bfa2970a646c0ba4af99838a08e9063e3da85f4176a7cd2", + "92e17db715bef4de971f75da73c790b68c2cc1b017edae91b4df283d5d9b6277", "pliego.css.receipt.json": - "a4d9f387b460b650d7c9b904b0f54721f9915c1ccea5753a41b646c503923aa7", + "4a4321f4142bfe0a06ebb419223fce693b82966ed2e72d4230cba71d52beaf3c", "pliego.tokens.json": - "0a45f1d6e48280ee7b357e2e4d58d82bfc27be715c9fd3f3385098608edd4e42", + "3298873e66ade310459593530c86419c05a973d2404b71882c2d0ae430030a28", }; const expectedWatchControlHashes = { - "app.css": "acdb0bfee613ddb3fdae0feb9a8e5118dc5f74d385ed53eb610ff4eefd05d454", + "app.css": "65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc", "app.css.map": "39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf", "app.manifest.json": - "8c1dc690c2cbb8102b6935a970e60938bbda7e50a0c0bf766c5c800305cce598", + "453f708c9c4edbd388eda1ee9c883212cd5d31421077d123cb1d32bc0b28d4bb", "pliego.css.findings.json": - "3affbc8e71ebb3096ce896e8dbfdff84073dfcc8b6eb8509431d768442545dad", + "8176a7153bc47f05a45bdacb4d437b07d36fa3dd2e5e9137690c0b2d768a1a24", "pliego.css.manifest.json": - "15831a792506ecf7b97e01907b7fdf1d1e4e2ccf7d90a1c2af105668adab3ed1", + "8fc31e3d221d70fe99931fcb8c1a2e414ed4a6e78145a667a179d7c3ada03543", "pliego.css.receipt.json": - "dc2532037337cfe285ff26162a7b0d404307d2d3c3a159ac95cf4862b9586b29", + "3f43fa1238c25ed4f17942c4b6ef42d2c1610fe6ffdc80e4153397066c2335c1", "pliego.tokens.json": - "0a45f1d6e48280ee7b357e2e4d58d82bfc27be715c9fd3f3385098608edd4e42", + "3298873e66ade310459593530c86419c05a973d2404b71882c2d0ae430030a28", }; const expectedDtcgControlHashes = { - "app.css": "5991b204608619242608e12e9b60ffad378c6ab185b91ca173260d9adf9dfdc3", + "app.css": "9a349f5059ffb3ace6c985dfe1fadcd9cb9ac45668b5cd0c00e26625e5a6c6c3", "app.css.map": "ce30a183a990c8dca861721b4c5ea953884fd4fb4898220023192e20abc62c3c", "app.manifest.json": - "b402c1804da5ac08d31000b8ecec1924b6c4955e6535d538c50fc4bd030ff8f2", + "9ed48b83a35dc176305c7761938435e9a0aa5e3c15907e3c3d80de1b248bad76", "pliego.css.findings.json": - "914d8463aa4e7abe3e7257879e0ecdb5df9605361cc803fa3a5e113ea2b6d00f", + "bb037e91d96ed3f2b786ee1091048bf3b7d1915fd9d9e98657a81097d0f4c5ce", "pliego.css.manifest.json": - "991801df2b11ad17e2adc5003e4e49d13d1c8050f829ab32c99b71965b79b924", + "8758c9d6b69e42f5adc48ba7d84f4fb026618af8737fea690185bb53dd7f1994", "pliego.css.receipt.json": - "b40d88514e19892b08b6478b94bce582b08dea8fa1d5eed26cfa47f40871af9a", + "aafe05a73d83a00aaa3be700bc554624389c60fb351e2f7a1c8d2cd6f0cae5aa", "pliego.tokens.json": - "59422650b57f45d5601a0dadce3ab1e9fc9bbf7e3c35d089f203516cf1c9a553", + "8659f9c443cb5c77c399fd741a27d476ec18887742c5459002fa6b82c22e864a", }; const expectedDtcgResolverSha256 = "5965f868707ad92b0164788b5a7b8b8a2234ce03d7e6c71e1df66d612801b876"; const expectedDtcgConfigHashes = { darkLight: - "sha256:1aa3a252d70b115f68861b96d8ecd9b485fdd62ad6408777d00ae46c2893d584", + "sha256:e6ce8e2578b2bbb68c0b533495dab2a781e5c7a32665d4c4a4845146f014ba3e", darkDark: - "sha256:e0c65ea08fe97f14b426058a23e4b42d31047afec100fb4164c41798f65f532e", + "sha256:5bab41c2b1a8d06d05fbaae1517d425a35b0bb8143fcdaeaee3c5faf48d9469e", }; const updateGoldens = process.env.PLIEGOCSS_UPDATE_GOLDENS === "1"; @@ -619,7 +619,7 @@ if ( manifest.schemaVersion !== 3 || manifest.styleIdFormatVersion !== 2 || manifest.classNameFormatVersion !== 1 || - manifest.themeIdFormatVersion !== 2 || + manifest.themeIdFormatVersion !== 3 || manifest.cssBytes !== firstCss.byteLength || manifest.cssSha256 !== sha256(firstCss) || manifest.styles.length !== 1 @@ -996,7 +996,7 @@ const dtcgCss = readFileSync(join(dtcgControlOutput, "app.css")); if ( dtcgControlManifest.inputs.configHash !== expectedDtcgConfigHashes.darkLight || - dtcgStyleManifest.themeId !== "a3a372ca8c3407a68302c8a71e89b121" || + dtcgStyleManifest.themeId !== "28acd9cd56861d1aea635ce531e240f5" || !dtcgCss.toString("utf8").includes("--color-brand:color(srgb .1 .1 .1)") ) { if (updateGoldens) { diff --git a/scripts/check-public-api.mjs b/scripts/check-public-api.mjs index 7563128..6e5c2b0 100644 --- a/scripts/check-public-api.mjs +++ b/scripts/check-public-api.mjs @@ -4,9 +4,18 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isolatedCargoEnvironment } from "./rust-target.mjs"; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const root = resolve(scriptDirectory, ".."); +const releaseCargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.96.0", +}); +const msrvCargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); const fixtureRoot = join(root, "integration-tests", "public-api-smoke"); const fixtureManifest = join(fixtureRoot, "Cargo.toml"); const fixtureSource = join(fixtureRoot, "src"); @@ -53,9 +62,9 @@ function assertIdentityVersions(document, schema, role) { document.schemaVersion !== schema || document.styleIdFormatVersion !== 2 || document.classNameFormatVersion !== 1 || - document.themeIdFormatVersion !== 2 + document.themeIdFormatVersion !== 3 ) { - fail(`${role} does not expose schema ${schema} with identity formats 2/1/2`); + fail(`${role} does not expose schema ${schema} with identity formats 2/1/3`); } } @@ -90,7 +99,7 @@ const cliBuild = run("cargo", [ "--message-format=json-render-diagnostics", "-p", "pliego-cssc", -]); +], { env: releaseCargoEnvironment }); let executable; for (const line of cliBuild.stdout.split(/\r?\n/u).filter(Boolean)) { let message; @@ -112,7 +121,6 @@ if (!executable) { fail("Cargo did not report the pliego-cssc executable it built"); } -const fixtureTarget = join(root, "target", "public-api-smoke"); const fixtureRun = run( "cargo", [ @@ -126,7 +134,7 @@ const fixtureRun = run( "--manifest-path", fixtureManifest, ], - { env: { ...process.env, CARGO_TARGET_DIR: fixtureTarget } }, + { env: msrvCargoEnvironment }, ); const publicLine = fixtureRun.stdout.trim(); const publicMatch = /^([0-9a-f]{32})\t(pc_[0-9a-z]+)$/u.exec(publicLine); @@ -199,14 +207,14 @@ try { { cwd: workspace }, ).stdout, ); - assertIdentityVersions(inspection, 2, "inspection"); + assertIdentityVersions(inspection, 3, "inspection"); const catalog = JSON.parse( run(executable, ["catalog", "--config", fixtureTheme, "--format", "json"], { cwd: workspace, }).stdout, ); - assertIdentityVersions(catalog, 3, "catalog"); + assertIdentityVersions(catalog, 4, "catalog"); const explanation = JSON.parse( run( @@ -243,7 +251,7 @@ try { inspectionSchema: inspection.schemaVersion, catalogSchema: catalog.schemaVersion, explainSchema: explanation.schemaVersion, - identityFormats: [2, 1, 2], + identityFormats: [2, 1, 3], renamedFacadeDependency: true, dtcgBuildMacro: true, tomlRegistryConvergence: true, diff --git a/scripts/check-release-readiness.mjs b/scripts/check-release-readiness.mjs index ade369d..dfa8da5 100644 --- a/scripts/check-release-readiness.mjs +++ b/scripts/check-release-readiness.mjs @@ -1,90 +1,81 @@ -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { AuthorityError, validateReleaseReadiness } from "./release-authority.mjs"; +import { + loadExternalAdoptionBundle, + validateExternalAdoptionBundle, +} from "./external-adoption-v1.mjs"; const root = resolve(import.meta.dirname, ".."); -const path = resolve(root, "docs/product/release-readiness-0.1.0.json"); -if (!existsSync(path)) throw new Error("release readiness document is missing"); - -const value = JSON.parse(readFileSync(path, "utf8")); -const fail = (message) => { - throw new Error(`release readiness: ${message}`); -}; -const requiredChecks = new Set([ - "targeted-corrections", - "brand-system", - "fast-profile-current-source", - "pliegors-current-contract", - "browser-current-source", - "package-replay-current-source", - "benchmark-evidence-current-source", - "supply-chain-policy", - "website-current-source", - "hosted-cross-os-current-source", - "registry-replay", - "final-promotion-authorization", -]); -const allowedStatuses = new Set([ - "passed", - "pending", - "blocked", - "not-authorized", - "not-applicable", -]); -const allowedEvidence = new Set(["measured", "inherited", "pending", "uncertain"]); - -if ( - value.schemaVersion !== 2 || - value.targetVersion !== "0.1.0" || - value.candidateVersion !== "0.1.0-rc.2" -) { - fail("header drifted"); -} -if (value.repository !== "https://github.com/celiumsai/pliegocss") { - fail("repository drifted"); -} +const args = process.argv.slice(2); +const enforceRelease = + (args.length === 1 && args[0] === "--profile=release") || + (args.length === 2 && args[0] === "--profile" && args[1] === "release"); if ( - value.repositoryVisibility !== "public" || - value.productStage !== "public-preview" + !enforceRelease && + !( + args.length === 0 || + (args.length === 1 && args[0] === "--validate") + ) ) { - fail("repository visibility and public-preview product stage drifted"); + throw new Error(`release readiness: unknown mode ${args.join(" ")}`); } -if (!Array.isArray(value.checks)) fail("checks must be an array"); -const ids = value.checks.map((check) => check.id); -if (new Set(ids).size !== ids.length) fail("check identifiers must be unique"); -for (const id of requiredChecks) { - if (!ids.includes(id)) fail(`missing required check ${id}`); -} -for (const check of value.checks) { - if (!allowedStatuses.has(check.status)) fail(`invalid status for ${check.id}`); - if (!allowedEvidence.has(check.evidenceClass)) { - fail(`invalid evidence class for ${check.id}`); + +try { + const value = JSON.parse( + readFileSync(resolve(root, "docs/product/release-readiness-0.1.0.json"), "utf8"), + ); + const summary = validateReleaseReadiness(value, { root }); + const externalAdoptionBundle = loadExternalAdoptionBundle(root); + const externalAdoption = validateExternalAdoptionBundle(externalAdoptionBundle, { root }); + const adoptionCheck = value.checks.find((check) => check.id === "external-adoption-evidence"); + if ( + JSON.stringify(adoptionCheck.requiredCoverage) !== + JSON.stringify(externalAdoptionBundle.authority.requiredCoverage) + ) { + throw new AuthorityError( + "external-adoption-evidence coverage drifted from the G7 authority", + ); } - if (check.status === "passed" && check.evidenceClass === "pending") { - fail(`${check.id} cannot pass on pending evidence`); + if (adoptionCheck.status === "passed" && externalAdoption.result !== "ready") { + throw new AuthorityError( + `external-adoption-evidence is passed while G7 is blocked by ${externalAdoption.missingCoverage.join(", ")}`, + ); } - if (!check.summary || typeof check.summary !== "string") { - fail(`${check.id} must include a summary`); + if (adoptionCheck.status === "passed") { + const requiredArtifacts = new Set([ + "benchmarks/external-adoption-v1/authority.json", + ...Object.values(externalAdoptionBundle.authority.evidence).map((entry) => entry.path), + externalAdoptionBundle.authority.adapterSupportPolicy.path, + ]); + const actualArtifacts = new Set(adoptionCheck.artifacts.map((artifact) => artifact.path)); + for (const path of requiredArtifacts) { + if (!actualArtifacts.has(path)) { + throw new AuthorityError( + `external-adoption-evidence passed without ${path}`, + ); + } + } + if ( + !adoptionCheck.references.some( + (reference) => reference.kind === "external-adoption-authority", + ) + ) { + throw new AuthorityError( + "external-adoption-evidence passed without its authority reference", + ); + } } + process.stdout.write(`${JSON.stringify({ ...summary, externalAdoption }, null, 2)}\n`); + if (enforceRelease && summary.result !== "ready") { + process.stderr.write( + `release readiness: promotion blocked by ${summary.blockers.join(", ")}\n`, + ); + process.exitCode = 1; + } +} catch (error) { + if (error instanceof AuthorityError) { + throw new Error(`release readiness: ${error.message}`); + } + throw error; } -const blocking = value.checks.filter( - (check) => check.requiredForPromotion && check.status !== "passed", -); -const derivedResult = blocking.length === 0 ? "ready" : "blocked"; -if (value.result !== derivedResult) fail("result does not match required checks"); -if (!value.promotionRule || typeof value.promotionRule !== "string") { - fail("promotion rule is missing"); -} - -process.stdout.write( - `${JSON.stringify( - { - schemaVersion: value.schemaVersion, - candidate: value.candidateVersion, - result: value.result, - passed: value.checks.filter((check) => check.status === "passed").length, - blocking: blocking.map((check) => check.id), - }, - null, - 2, - )}\n`, -); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index bef9983..a736123 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -23,6 +24,42 @@ const gates = Object.freeze([ command: "cargo", args: ["check", "--workspace", "--all-targets", "--locked"], }, + { + id: "engine-boundary", + tier: "fast", + command: "pnpm", + args: ["check:engine-boundary"], + }, + { + id: "benchmark-authority-v2", + tier: "fast", + command: "pnpm", + args: ["check:benchmark-authority"], + }, + { + id: "browser-output-authority", + tier: "fast", + command: "pnpm", + args: ["check:browser-output-authority"], + }, + { + id: "adapter-coexistence-authority", + tier: "fast", + command: "pnpm", + args: ["check:adapter-coexistence-authority"], + }, + { + id: "external-adoption-authority", + tier: "fast", + command: "pnpm", + args: ["check:external-adoption-authority"], + }, + { + id: "repository-distribution-contract", + tier: "fast", + command: "pnpm", + args: ["check:repository-distribution"], + }, { id: "rust-tests", tier: "fast", @@ -101,16 +138,34 @@ const gates = Object.freeze([ args: ["test", "-p", "pliego-css-usage", "--test", "generic_css_usage", "--locked"], }, { - id: "release-readiness", + id: "release-authority-contract-tests", tier: "fast", command: "node", - args: ["scripts/check-release-readiness.mjs"], + args: ["scripts/release-authority.test.mjs"], }, { - id: "hosted-browser-matrix", + id: "release-readiness-contract", + tier: "fast", + command: "node", + args: ["scripts/check-release-readiness.mjs", "--validate"], + }, + { + id: "hosted-browser-matrix-contract", + tier: "fast", + command: "node", + args: ["scripts/check-hosted-browser-matrix.mjs", "--validate"], + }, + { + id: "document-authority", tier: "fast", command: "node", - args: ["scripts/check-hosted-browser-matrix.mjs"], + args: ["scripts/check-document-authority.mjs"], + }, + { + id: "site-markdown-docs", + tier: "fast", + command: "pnpm", + args: ["check:site-docs"], }, { id: "getting-started", @@ -118,6 +173,12 @@ const gates = Object.freeze([ command: "node", args: ["scripts/check-getting-started.mjs"], }, + { + id: "benchmark-authority-smoke", + tier: "integration", + command: "pnpm", + args: ["check:benchmark-smoke"], + }, { id: "standards-provenance", tier: "fast", @@ -179,11 +240,19 @@ const gates = Object.freeze([ command: "node", args: ["scripts/check-representative-framework-routes.mjs"], }, + { + id: "adapter-coexistence", + tier: "integration", + command: "pnpm", + args: ["check:adapter-coexistence"], + requires: ["PLIEGOCSS_RUN_BROWSERS"], + }, { id: "lsp", tier: "integration", command: "node", args: ["scripts/check-lsp-integration.mjs"], + rustToolchain: "1.85.0", }, { id: "vscode", @@ -197,6 +266,7 @@ const gates = Object.freeze([ command: "pnpm", args: ["--filter", "pliegocss-vscode", "test:host"], requires: ["PLIEGOCSS_RUN_EDITOR_HOSTS"], + rustToolchain: "1.85.0", }, { id: "neovim", @@ -204,6 +274,7 @@ const gates = Object.freeze([ command: "node", args: ["scripts/check-neovim-client.mjs"], requires: ["PLIEGOCSS_RUN_EDITOR_HOSTS"], + rustToolchain: "1.85.0", }, { id: "pliegors-guide", @@ -233,12 +304,31 @@ const gates = Object.freeze([ requires: ["PLIEGORS_ROOT", "PLIEGOCSS_RUN_BROWSERS"], }, + { + id: "release-readiness", + tier: "release", + command: "node", + args: ["scripts/check-release-readiness.mjs", "--profile=release"], + }, + { + id: "external-adoption", + tier: "release", + command: "pnpm", + args: ["check:external-adoption"], + }, + { + id: "hosted-browser-matrix", + tier: "release", + command: "node", + args: ["scripts/check-hosted-browser-matrix.mjs", "--profile=release"], + }, { id: "public-api", tier: "release", command: "pnpm", args: ["check:api"] }, { id: "properties", tier: "release", command: "pnpm", args: ["check:properties"], + rustToolchain: "1.85.0", }, { id: "determinism", @@ -259,6 +349,12 @@ const gates = Object.freeze([ command: "pnpm", args: ["check:evidence"], }, + { + id: "benchmark-oracle-live", + tier: "release", + command: "pnpm", + args: ["check:benchmark-oracle"], + }, { id: "media-query-merge", tier: "release", @@ -337,17 +433,18 @@ function listedGate(gate) { tier: gate.tier, command: [gate.command, ...gate.args], requires: gate.requires ?? [], + rustToolchain: gate.rustToolchain ?? "active", }; } -function spawnGate(gate) { +function spawnGate(gate, environment) { if (process.platform === "win32" && gate.command === "pnpm") { return spawnSync( process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", gate.command, ...gate.args], { cwd: root, - env: process.env, + env: environment, encoding: "utf8", stdio: "inherit", windowsHide: true, @@ -356,7 +453,7 @@ function spawnGate(gate) { } return spawnSync(gate.command, gate.args, { cwd: root, - env: process.env, + env: environment, encoding: "utf8", stdio: "inherit", windowsHide: true, @@ -371,6 +468,7 @@ function run(profile) { } const results = []; + const targetEnvironments = new Map(); for (const gate of selected.gates) { const missing = (gate.requires ?? []).filter((name) => !process.env[name]); if (missing.length > 0) { @@ -380,8 +478,18 @@ function run(profile) { ); continue; } + const toolchain = gate.rustToolchain ?? "active"; + let environment = targetEnvironments.get(toolchain); + if (!environment) { + environment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: gate.rustToolchain, + }); + targetEnvironments.set(toolchain, environment); + process.stdout.write(`[target] ${toolchain}: ${environment.CARGO_TARGET_DIR}\n`); + } process.stdout.write(`[running] ${gate.id}\n`); - const result = spawnGate(gate); + const result = spawnGate(gate, environment); if (result.error) { results.push({ id: gate.id, diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs index 5d72595..4fdd846 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { targetDirectoryForRustcIdentity } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const script = resolve(root, "scripts", "check-release.mjs"); @@ -33,6 +34,16 @@ assert.deepEqual(fast.includes, ["fast"]); assert(fast.gates.some((gate) => gate.id === "rust-clippy")); assert(fast.gates.some((gate) => gate.id === "standards-provenance")); assert(fast.gates.some((gate) => gate.id === "brand-system")); +assert(fast.gates.some((gate) => gate.id === "release-authority-contract-tests")); +assert(fast.gates.some((gate) => gate.id === "release-readiness-contract")); +assert(fast.gates.some((gate) => gate.id === "hosted-browser-matrix-contract")); +assert(fast.gates.some((gate) => gate.id === "document-authority")); +assert(fast.gates.some((gate) => gate.id === "site-markdown-docs")); +assert(fast.gates.some((gate) => gate.id === "benchmark-authority-v2")); +assert(fast.gates.some((gate) => gate.id === "browser-output-authority")); +assert(fast.gates.some((gate) => gate.id === "external-adoption-authority")); +assert(fast.gates.some((gate) => gate.id === "repository-distribution-contract")); +assert(!fast.gates.some((gate) => gate.id === "release-readiness")); assert(!fast.gates.some((gate) => gate.id === "getting-started")); const integration = list("integration"); @@ -40,10 +51,19 @@ assert.deepEqual(integration.includes, ["fast", "integration"]); assert(integration.gates.some((gate) => gate.id === "plain-html")); assert(integration.gates.some((gate) => gate.id === "getting-started")); assert(integration.gates.some((gate) => gate.id === "pliegors-browser")); +assert(integration.gates.some((gate) => gate.id === "benchmark-authority-smoke")); +for (const id of ["lsp", "vscode-host", "neovim"]) { + assert.equal( + integration.gates.find((gate) => gate.id === id)?.rustToolchain, + "1.85.0", + `${id} must share the MSRV target namespace with its LSP fixture`, + ); +} const release = list("release"); assert.deepEqual(release.includes, ["fast", "integration", "release"]); assert(release.gates.some((gate) => gate.id === "packages")); +assert(release.gates.some((gate) => gate.id === "repository-distribution-contract")); assert(release.gates.some((gate) => gate.id === "benchmark-evidence")); assert(release.gates.some((gate) => gate.id === "media-query-merge")); assert(release.gates.some((gate) => gate.id === "reachability-pruning")); @@ -51,6 +71,16 @@ assert(release.gates.some((gate) => gate.id === "supply-chain")); assert(release.gates.some((gate) => gate.id === "site")); assert(release.gates.some((gate) => gate.id === "site-deployment")); assert(release.gates.some((gate) => gate.id === "migration-real-corpus")); +assert(release.gates.some((gate) => gate.id === "benchmark-oracle-live")); +assert(release.gates.some((gate) => gate.id === "external-adoption")); +assert.deepEqual( + release.gates.find((gate) => gate.id === "release-readiness")?.command, + ["node", "scripts/check-release-readiness.mjs", "--profile=release"], +); +assert.deepEqual( + release.gates.find((gate) => gate.id === "hosted-browser-matrix")?.command, + ["node", "scripts/check-hosted-browser-matrix.mjs", "--profile=release"], +); assert.equal( new Set(release.gates.map((gate) => gate.id)).size, release.gates.length, @@ -68,6 +98,37 @@ assert.match( /process\.platform === "win32" && gate\.command === "pnpm"/u, "Windows must invoke pnpm command shims through cmd.exe", ); +const targetBase = resolve(root, "target", "isolation-contract"); +const targetEnvironment = { CARGO_TARGET_DIR: targetBase }; +const stableTarget = targetDirectoryForRustcIdentity( + root, + targetEnvironment, + "release: 1.85.0\nhost: x86_64-pc-windows-msvc\ncommit-hash: aaaa", +); +assert.equal( + stableTarget, + targetDirectoryForRustcIdentity( + root, + targetEnvironment, + "release: 1.85.0\nhost: x86_64-pc-windows-msvc\ncommit-hash: aaaa", + ), + "one exact toolchain identity must map to one deterministic target", +); +assert.notEqual( + stableTarget, + targetDirectoryForRustcIdentity( + root, + targetEnvironment, + "release: 1.96.0\nhost: x86_64-pc-windows-msvc\ncommit-hash: bbbb", + ), + "different Rust toolchains must never share build artifacts", +); +assert.match(stableTarget, /toolchains/u); +assert.equal( + release.gates.find((gate) => gate.id === "properties")?.rustToolchain, + "1.85.0", + "the MSRV property gate must select its own target namespace", +); for (const dependency of ["pliego-dom", "pliego-ssg"]) { assert.match( pliegorsDevLoopManifest, @@ -85,6 +146,11 @@ assert.match( /join\(cargoOverrideDirectory, "config\.toml"\)/u, "the development-loop gate must materialize a local Cargo path override", ); +assert.match( + pliegorsDevLoopSource, + /"generate-lockfile", "--manifest-path", manifestPath/u, + "the development-loop gate must bind its copied lockfile to the path-overridden source graph", +); assert.doesNotMatch( pliegorsDevLoopSource, /\.\.\/\.\.\/\.\.\/pliegors\/crates/u, diff --git a/scripts/check-repair-corpus.mjs b/scripts/check-repair-corpus.mjs index 115bc01..3461334 100644 --- a/scripts/check-repair-corpus.mjs +++ b/scripts/check-repair-corpus.mjs @@ -2,12 +2,14 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const corpusPath = resolve(root, "benchmarks", "repair-corpus", "cases.json"); const corpusBytes = readFileSync(corpusPath); const corpus = JSON.parse(corpusBytes.toString("utf8")); const reportPrefix = "PLIEGOCSS_REPAIR_CORPUS_REPORT "; +const cargoEnvironment = isolatedCargoEnvironment(root); function fail(message) { throw new Error(message); @@ -80,6 +82,7 @@ const result = spawnSync( ["test", "--locked", "-p", "pliego-css-agent", "--test", "repair_corpus", "--", "--nocapture"], { cwd: root, + env: cargoEnvironment, encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024, diff --git a/scripts/check-repository-distribution.mjs b/scripts/check-repository-distribution.mjs new file mode 100644 index 0000000..c03b8e1 --- /dev/null +++ b/scripts/check-repository-distribution.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, isAbsolute, relative, resolve } from "node:path"; +import { + ROOT, + fail, + repositoryVersion, + safeFilename, + validatePackageManifest, + validatePolicy, +} from "./repository-distribution.mjs"; +import { resolveBinary, selectTarget } from "../packages/cli/bin/launcher.mjs"; + +function run(script, args) { + const result = spawnSync(process.execPath, [resolve(ROOT, "scripts", script), ...args], { + cwd: ROOT, + encoding: "utf8", + windowsHide: true, + }); + if (result.error || result.status !== 0) { + fail(`${script} failed: ${result.error?.message ?? `${result.stdout}${result.stderr}`.trim()}`); + } + return result.stdout.trim(); +} + +const policy = validatePolicy(); +validatePackageManifest(undefined, policy); +const workflow = readFileSync(resolve(ROOT, ".github", "workflows", "distribution.yml"), "utf8"); +for (const required of [ + "cargo +1.96.0 install cargo-cyclonedx --version '=0.5.9' --locked", + "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271", + "version: 11.7.0", + "actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6", + "merge-multiple: false", + "repos/${GITHUB_REPOSITORY}/immutable-releases", + "--draft", +]) { + assert(workflow.includes(required), `distribution workflow is missing: ${required}`); +} +assert(!/\b(?:npm|pnpm) publish\b/u.test(workflow), "distribution workflow must not publish to npmjs"); +assert.throws(() => safeFilename("../escape.tgz", "fixture artifact"), /portable filename/u); +assert.throws( + () => + validatePackageManifest( + { + ...structuredClone(validatePackageManifest(undefined, policy)), + scripts: { postinstall: "node fetch-binary.mjs" }, + }, + policy, + ), + /forbidden lifecycle script/u, +); +assert.equal(selectTarget("win32", "x64").id, "x86_64-pc-windows-msvc"); +assert.equal(selectTarget("linux", "x64").id, "x86_64-unknown-linux-gnu"); +assert.equal(selectTarget("darwin", "arm64").id, "aarch64-apple-darwin"); +assert.throws(() => selectTarget("linux", "arm64"), /no repository binary/u); +assert.match( + resolveBinary("package", "pliego-cssc", "linux", "x64").replaceAll("\\", "/"), + /\/package\/vendor\/x86_64-unknown-linux-gnu\/pliego-cssc$/u, +); +assert.throws( + () => resolveBinary("/package", "unknown", "linux", "x64"), + /unknown PliegoCSS repository binary/u, +); + +const temporaryRoot = mkdtempSync(resolve(tmpdir(), "pliegocss-distribution-contract-")); +const fixtureInput = resolve(ROOT, "target", `distribution-contract-${basename(temporaryRoot)}`, "native"); +const fixtureOutput = resolve(ROOT, "target", `distribution-contract-${basename(temporaryRoot)}`, "bundle"); +const version = repositoryVersion(); +const sourceCommit = "1".repeat(40); +const gitTree = "2".repeat(40); +try { + for (const target of policy.targets) { + const source = resolve(temporaryRoot, target.id); + mkdirSync(source, { recursive: true }); + const magic = { + pe: Buffer.from([0x4d, 0x5a, 0x90, 0x00]), + elf: Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + "mach-o": Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), + }[target.format]; + for (const binary of policy.binaries) { + const bytes = Buffer.alloc(64 * 1024, 0x5a); + magic.copy(bytes, 0); + writeFileSync(resolve(source, `${binary}${target.extension}`), bytes); + const sbom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: `urn:uuid:00000000-0000-4000-8000-${binary === "pliego-cssc" ? "000000000001" : "000000000002"}`, + version: 1, + metadata: { + component: { type: "application", name: binary, version }, + }, + components: [], + }; + writeFileSync(resolve(source, `${binary}.cdx.json`), `${JSON.stringify(sbom)}\n`); + } + run("prepare-native-distribution.mjs", [ + `--target=${target.id}`, + `--binary-dir=${relative(ROOT, source)}`, + `--cli-sbom=${relative(ROOT, resolve(source, "pliego-cssc.cdx.json"))}`, + `--lsp-sbom=${relative(ROOT, resolve(source, "pliego-css-lsp.cdx.json"))}`, + `--output=${relative(ROOT, resolve(fixtureInput, `native-${target.id}`))}`, + `--source-commit=${sourceCommit}`, + `--git-tree=${gitTree}`, + ]); + } + run("build-repository-distribution.mjs", [ + `--input=${relative(ROOT, fixtureInput)}`, + `--output=${relative(ROOT, fixtureOutput)}`, + `--source-commit=${sourceCommit}`, + `--git-tree=${gitTree}`, + "--allow-dirty", + ]); + const result = JSON.parse( + run("check-distribution-bundle.mjs", [ + "--", + `--root=${relative(ROOT, resolve(fixtureOutput, "release-assets"))}`, + ]), + ); + assert.equal(result.result, "passed"); + assert.equal(result.targets, 3); + assert.equal(result.pnpmPackage, `pliegocss-pnpm-${version}.tgz`); + assert.equal(result.npmjsPublished, false); + assert.equal(result.lifecycleScripts, false); +} finally { + const contractRoot = resolve(fixtureInput, ".."); + if (!contractRoot.startsWith(resolve(ROOT, "target"))) { + fail(`refusing to remove unsafe contract path: ${contractRoot}`); + } + rmSync(contractRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + const local = relative(resolve(tmpdir()), temporaryRoot); + if (!local || local.startsWith("..") || isAbsolute(local)) { + fail(`refusing to remove unsafe fixture path: ${temporaryRoot}`); + } + rmSync(temporaryRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +} + +process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + result: "passed", + policy: "repository-only", + packageManager: "pnpm@11.7.0", + npmjsPublished: false, + lifecycleScripts: false, + targets: policy.targets.map((target) => target.id), + }, null, 2)}\n`, +); diff --git a/scripts/check-representative-css-modules.mjs b/scripts/check-representative-css-modules.mjs index e8e266c..1f506db 100644 --- a/scripts/check-representative-css-modules.mjs +++ b/scripts/check-representative-css-modules.mjs @@ -2,10 +2,11 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const fixture = join(root, "integration-tests", "representative", "css-modules-consumer"); -const executable = join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); +const executable = join(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); function fail(message, detail) { throw new Error(detail === undefined ? message : `${message}\n${JSON.stringify(detail, null, 2)}`); } function run(command, args, cwd = root) { const result = spawnSync(command, args, { cwd, encoding: "utf8", windowsHide: true }); diff --git a/scripts/check-representative-framework-routes.mjs b/scripts/check-representative-framework-routes.mjs index d027b27..8da7c35 100644 --- a/scripts/check-representative-framework-routes.mjs +++ b/scripts/check-representative-framework-routes.mjs @@ -3,11 +3,12 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const fixture = join(root, "integration-tests", "representative", "framework-neutral-routes"); const reachabilityPath = join(fixture, ".reachability.generated.json"); -const cli = join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); +const cli = join(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); function fail(message, detail) { throw new Error(detail === undefined ? message : `${message}\n${JSON.stringify(detail, null, 2)}`); } function run(command, args, cwd = root) { const result = spawnSync(command, args, { cwd, encoding: "utf8", windowsHide: true }); diff --git a/scripts/check-representative-gap-map.mjs b/scripts/check-representative-gap-map.mjs index b50407d..25b10d5 100644 --- a/scripts/check-representative-gap-map.mjs +++ b/scripts/check-representative-gap-map.mjs @@ -4,7 +4,7 @@ import { resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); const document = JSON.parse(readFileSync(resolve(root, "docs/product/representative-applications-gap-map.json"), "utf8")); function fail(message) { throw new Error(`representative gap map: ${message}`); } -if (document.schemaVersion !== 1 || document.selectedNext !== "standard-css-transform") fail("header drifted"); +if (document.schemaVersion !== 1 || document.selectedNext !== "external-pilot-evidence") fail("header drifted"); if (!Array.isArray(document.applications) || document.applications.length !== 5) fail("exactly five applications required"); if (!Array.isArray(document.priorities) || document.priorities.length !== 5) fail("exactly five priorities required"); const ids = new Set(); diff --git a/scripts/check-representative-plain-html-audit.mjs b/scripts/check-representative-plain-html-audit.mjs index dd76d0a..5f408ab 100644 --- a/scripts/check-representative-plain-html-audit.mjs +++ b/scripts/check-representative-plain-html-audit.mjs @@ -3,10 +3,11 @@ import { createHash } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const fixture = join(root, "integration-tests", "representative", "plain-html-audit"); -const cli = join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); +const cli = join(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); function fail(message, detail) { throw new Error(detail === undefined ? message : `${message}\n${JSON.stringify(detail, null, 2)}`); diff --git a/scripts/check-representative-rust-control.mjs b/scripts/check-representative-rust-control.mjs index 27dcbe0..9167a0d 100644 --- a/scripts/check-representative-rust-control.mjs +++ b/scripts/check-representative-rust-control.mjs @@ -3,10 +3,15 @@ import { createHash } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const fixture = join(root, "integration-tests", "representative", "rust-typed-control"); -const cli = join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); +const cli = join(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); +const msrvEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); function fail(message, detail) { throw new Error(detail === undefined ? message : `${message}\n${JSON.stringify(detail, null, 2)}`); } function run(command, args, cwd = root) { const result = spawnSync(command, args, { cwd, encoding: "utf8", windowsHide: true }); @@ -16,7 +21,13 @@ function run(command, args, cwd = root) { } function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } for (const name of ["Cargo.toml", "src/main.rs"]) if (!existsSync(join(fixture, name))) fail(`missing ${name}`); -run("cargo", ["+1.85.0", "check", "--quiet", "--locked", "--manifest-path", join(fixture, "Cargo.toml")]); +const msrv = spawnSync( + "cargo", + ["+1.85.0", "check", "--quiet", "--locked", "--manifest-path", join(fixture, "Cargo.toml")], + { cwd: root, env: msrvEnvironment, encoding: "utf8", windowsHide: true }, +); +if (msrv.error) throw msrv.error; +if (msrv.status !== 0) fail("MSRV fixture check failed", msrv); run("cargo", ["build", "--quiet", "--locked", "-p", "pliego-cssc"]); let output; try { diff --git a/scripts/check-representative-vite-tailwind-replacement.mjs b/scripts/check-representative-vite-tailwind-replacement.mjs index 6cfb495..23349b4 100644 --- a/scripts/check-representative-vite-tailwind-replacement.mjs +++ b/scripts/check-representative-vite-tailwind-replacement.mjs @@ -2,11 +2,12 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const source = resolve(root, "integration-tests/representative/vite-tailwind-inventory"); const stage = resolve(root, "target/tests/vite-tailwind-plan-replacement"); -const binary = resolve(root, process.platform === "win32" ? "target/debug/pliego-cssc.exe" : "target/debug/pliego-cssc"); +const binary = resolve(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); const run = (args, cwd = stage) => { const result = spawnSync(binary, args, { cwd, encoding: "utf8" }); if (result.status !== 0) throw new Error(`${args.join(" ")} failed:\n${result.stderr}`); diff --git a/scripts/check-representative-vite-tailwind-reversible.mjs b/scripts/check-representative-vite-tailwind-reversible.mjs index 27315e9..2058181 100644 --- a/scripts/check-representative-vite-tailwind-reversible.mjs +++ b/scripts/check-representative-vite-tailwind-reversible.mjs @@ -2,10 +2,11 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const fixture = resolve(root, "integration-tests/representative/vite-tailwind-inventory"); -const binary = resolve(root, process.platform === "win32" ? "target/debug/pliego-cssc.exe" : "target/debug/pliego-cssc"); +const binary = resolve(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); const sidecar = join(fixture, "pliego.migration.css"); const receipt = join(fixture, "pliego.migration.receipt.json"); const files = ["package.json", "index.html", "src/app.css", "src/main.ts"]; diff --git a/scripts/check-representative-vite-tailwind-template-group.mjs b/scripts/check-representative-vite-tailwind-template-group.mjs index 5aa392a..0ce2f8f 100644 --- a/scripts/check-representative-vite-tailwind-template-group.mjs +++ b/scripts/check-representative-vite-tailwind-template-group.mjs @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -const root=resolve(import.meta.dirname,".."),source=resolve(root,"integration-tests/representative/vite-tailwind-inventory"),stage=resolve(root,"target/tests/vite-tailwind-css-template-group"),binary=resolve(root,process.platform==="win32"?"target/debug/pliego-cssc.exe":"target/debug/pliego-cssc"); +import { cargoTargetRoot } from "./rust-target.mjs"; +const root=resolve(import.meta.dirname,".."),source=resolve(root,"integration-tests/representative/vite-tailwind-inventory"),stage=resolve(root,"target/tests/vite-tailwind-css-template-group"),binary=resolve(cargoTargetRoot(root),"debug",process.platform==="win32"?"pliego-cssc.exe":"pliego-cssc"); const run=(args)=>{const r=spawnSync(binary,args,{cwd:stage,encoding:"utf8"});if(r.status!==0)throw new Error(`${args.join(' ')} failed:\n${r.stderr}`);return r.stdout};const sha=b=>createHash("sha256").update(b).digest("hex"); rmSync(stage,{recursive:true,force:true});mkdirSync(stage,{recursive:true});cpSync(source,stage,{recursive:true}); const cssFile=resolve(stage,"src/app.css"),htmlFile=resolve(stage,"index.html"),cssBefore=readFileSync(cssFile),htmlBefore=readFileSync(htmlFile); diff --git a/scripts/check-representative-vite-tailwind-theme-root.mjs b/scripts/check-representative-vite-tailwind-theme-root.mjs index 265b6a0..fd4b761 100644 --- a/scripts/check-representative-vite-tailwind-theme-root.mjs +++ b/scripts/check-representative-vite-tailwind-theme-root.mjs @@ -2,11 +2,12 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const source = resolve(root, "integration-tests/representative/vite-tailwind-inventory"); const stage = resolve(root, "target/tests/vite-tailwind-theme-root"); -const binary = resolve(root, process.platform === "win32" ? "target/debug/pliego-cssc.exe" : "target/debug/pliego-cssc"); +const binary = resolve(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); const run = (args) => { const result = spawnSync(binary, args, {cwd:stage,encoding:"utf8"}); if(result.status!==0) throw new Error(`${args.join(' ')} failed:\n${result.stderr}`); return result.stdout; }; const sha = (bytes) => createHash("sha256").update(bytes).digest("hex"); rmSync(stage,{recursive:true,force:true}); mkdirSync(stage,{recursive:true}); cpSync(source,stage,{recursive:true}); diff --git a/scripts/check-representative-vite-tailwind-utility.mjs b/scripts/check-representative-vite-tailwind-utility.mjs index 5529d76..b3ca0d3 100644 --- a/scripts/check-representative-vite-tailwind-utility.mjs +++ b/scripts/check-representative-vite-tailwind-utility.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; -const root=resolve(import.meta.dirname,".."),source=resolve(root,"integration-tests/representative/vite-tailwind-inventory"),stage=resolve(root,"target/tests/vite-tailwind-utility-projection"),binary=resolve(root,process.platform==="win32"?"target/debug/pliego-cssc.exe":"target/debug/pliego-cssc"); +import { cargoTargetRoot } from "./rust-target.mjs"; +const root=resolve(import.meta.dirname,".."),source=resolve(root,"integration-tests/representative/vite-tailwind-inventory"),stage=resolve(root,"target/tests/vite-tailwind-utility-projection"),binary=resolve(cargoTargetRoot(root),"debug",process.platform==="win32"?"pliego-cssc.exe":"pliego-cssc"); const run=(args)=>{const r=spawnSync(binary,args,{cwd:stage,encoding:"utf8"});if(r.status!==0)throw new Error(`${args.join(' ')} failed:\n${r.stderr}`);return r.stdout};const sha=b=>createHash("sha256").update(b).digest("hex"); rmSync(stage,{recursive:true,force:true});mkdirSync(stage,{recursive:true});cpSync(source,stage,{recursive:true});const plan=JSON.parse(run(["migration-project-plan","."]));const proposal=plan.proposals.find(x=>x.kind==="utility-projection");if(!proposal||proposal.automatic!==false)throw new Error("utility proposal absent");const file=resolve(stage,proposal.file),before=readFileSync(file);const text=before.toString();const m=text.match(/@utility\s+([A-Za-z0-9_-]+)\s*\{([^{}]*)\}/);if(!m)throw new Error("static utility absent");const after=Buffer.concat([before,Buffer.from(`/* PliegoCSS projected Tailwind utility */\n.${m[1]} {${m[2]}}\n`)]);if(sha(after)!==proposal.afterSha256)throw new Error("proposal hash mismatch");writeFileSync(resolve(stage,"before.css"),before);writeFileSync(resolve(stage,"after.css"),after);run(["migration-replace-apply","--file",proposal.file,"--before","before.css","--after","after.css","--receipt","receipt.json"]);const applied=JSON.parse(run(["migration-project-inventory","."])).sources.find(x=>x.file===proposal.file);if(applied.sourceSha256!==proposal.afterSha256)throw new Error("applied inventory mismatch");run(["migration-replace-rollback","--file",proposal.file,"--receipt","receipt.json"]);if(sha(readFileSync(file))!==proposal.beforeSha256)throw new Error("rollback mismatch");rmSync(stage,{recursive:true,force:true});process.stdout.write(`${JSON.stringify({schemaVersion:1,fixture:"vite-tailwind-inventory-copy",proposal,apply:"passed",inventory:"passed",rollback:"passed"},null,2)}\n`); diff --git a/scripts/check-representative-vite-tailwind.mjs b/scripts/check-representative-vite-tailwind.mjs index 73c56e9..3f738d0 100644 --- a/scripts/check-representative-vite-tailwind.mjs +++ b/scripts/check-representative-vite-tailwind.mjs @@ -2,10 +2,11 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; +import { cargoTargetRoot } from "./rust-target.mjs"; const root = resolve(import.meta.dirname, ".."); const fixture = join(root, "integration-tests", "representative", "vite-tailwind-inventory"); -const executable = join(root, "target", "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); +const executable = join(cargoTargetRoot(root), "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc"); function fail(message, detail) { throw new Error(detail === undefined ? message : `${message}\n${JSON.stringify(detail, null, 2)}`); diff --git a/scripts/check-standards-provenance.mjs b/scripts/check-standards-provenance.mjs index 198acb7..e611ef8 100644 --- a/scripts/check-standards-provenance.mjs +++ b/scripts/check-standards-provenance.mjs @@ -7,8 +7,8 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const realRoot = realpathSync(root); const attributionCommand = "node ./scripts/check-standards-provenance.mjs"; const reviewedFileDigests = Object.freeze({ - "THIRD_PARTY_NOTICES.md": "00c94fd9f07291966d5ffa252c6cf01d86da7a6ebecc8d526c7024f2cd36ce60", - "docs/reference/standards-provenance.md": "2a48b22dd41fe7cdc33e2f7f075a1af261fdf5bfdba1f0ef6daa202151bf74fd", + "THIRD_PARTY_NOTICES.md": "0062833f236f77ab9cb7386fc575d7e9e9fbc68abe8e3c75590c4de59e5ff09d", + "docs/reference/standards-provenance.md": "b3a0b68daaab8af4d832ff71df0e633d14f4f6052cf21961463c90a8645ddbdf", }); const sourceKeys = [ "id", @@ -31,7 +31,10 @@ const expectedSources = JSON.parse(`[ {"id":"dtcg-resolver-2025.10","name":"Design Tokens Resolver Module 2025.10","kind":"specification","version":"2025.10","status":"final-community-group-report","license":"W3C-Community-Final-Specification-Agreement","url":"https://www.w3.org/community/reports/design-tokens/CG-FINAL-resolver-20251028/","relationship":"exchange-specification","usage":"implemented-contract","evidence":["crates/pliego-css-config/src/dtcg_resolver.rs","docs/reference/dtcg-bridge.md"],"integrity":[]}, {"id":"lightningcss","name":"Lightning CSS","kind":"software","version":"1.0.0-alpha.71","status":"versioned-crate","license":"MPL-2.0","url":"https://github.com/parcel-bundler/lightningcss","relationship":"css-backend","usage":"build-time","evidence":["Cargo.lock"],"integrity":[{"subject":"lightningcss@1.0.0-alpha.71","value":"cargo-checksum:cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937"}]}, {"id":"mediaqueries-5","name":"Media Queries Level 5","kind":"specification","version":"5","status":"w3c-working-draft","license":"W3C-Software-and-Document-License-2023","url":"https://www.w3.org/TR/2026/WD-mediaqueries-5-20260219/","relationship":"css-specification","usage":"audit-reference","evidence":["docs/reference/accessibility-policy.md"],"integrity":[]}, - {"id":"tailwindcss","name":"Tailwind CSS and CLI","kind":"software","version":"4.3.2","status":"versioned-package","license":"MIT","url":"https://github.com/tailwindlabs/tailwindcss","relationship":"benchmark-baseline","usage":"benchmark-only","evidence":["benchmarks/tailwind-v4/input.css","package.json","pnpm-lock.yaml"],"integrity":[{"subject":"@tailwindcss/cli@4.3.2","value":"sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw=="},{"subject":"pnpm-lock.yaml","value":"sha256:333f16de16053c85708ea74473a3008f314e1c3ee45490106e1462df06de93f9"},{"subject":"tailwindcss@4.3.2","value":"sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="}]}, + {"id":"pixelmatch","name":"pixelmatch","kind":"software","version":"7.2.0","status":"versioned-package","license":"ISC","url":"https://github.com/mapbox/pixelmatch","relationship":"browser-certification","usage":"test-only","evidence":["benchmarks/browser-output-certification-v1/authority.json","package.json","pnpm-lock.yaml","scripts/browser-output-certification.mjs"],"integrity":[{"subject":"pixelmatch@7.2.0","value":"sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg=="},{"subject":"pnpm-lock.yaml","value":"sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5"}]}, + {"id":"playwright","name":"Playwright","kind":"software","version":"1.61.1","status":"versioned-package","license":"Apache-2.0","url":"https://github.com/microsoft/playwright","relationship":"browser-certification","usage":"test-only","evidence":[".github/workflows/browser-output-certification.yml","benchmarks/browser-output-certification-v1/authority.json","package.json","pnpm-lock.yaml","scripts/browser-output-certification.mjs"],"integrity":[{"subject":"playwright-core@1.61.1","value":"sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="},{"subject":"playwright@1.61.1","value":"sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="},{"subject":"pnpm-lock.yaml","value":"sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5"}]}, + {"id":"pngjs","name":"pngjs","kind":"software","version":"7.0.0","status":"versioned-package","license":"MIT","url":"https://github.com/pngjs/pngjs","relationship":"browser-certification","usage":"test-only","evidence":["benchmarks/browser-output-certification-v1/authority.json","package.json","pnpm-lock.yaml","scripts/browser-output-certification.mjs"],"integrity":[{"subject":"pngjs@7.0.0","value":"sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="},{"subject":"pnpm-lock.yaml","value":"sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5"}]}, + {"id":"tailwindcss","name":"Tailwind CSS and CLI","kind":"software","version":"4.3.3","status":"versioned-package","license":"MIT","url":"https://github.com/tailwindlabs/tailwindcss","relationship":"benchmark-baseline","usage":"benchmark-only","evidence":["benchmarks/benchmark-authority-v2/oracle.json","benchmarks/tailwind-v4/input.css","package.json","pnpm-lock.yaml"],"integrity":[{"subject":"@tailwindcss/cli@4.3.2","value":"sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw=="},{"subject":"@tailwindcss/cli@4.3.3","value":"sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw=="},{"subject":"pnpm-lock.yaml","value":"sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5"},{"subject":"tailwindcss@3.4.19","value":"sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="},{"subject":"tailwindcss@4.3.2","value":"sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="},{"subject":"tailwindcss@4.3.3","value":"sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="}]}, {"id":"wcag-2.2","name":"Web Content Accessibility Guidelines (WCAG) 2.2","kind":"specification","version":"2.2","status":"w3c-recommendation","license":"W3C-Document-License-2023","url":"https://www.w3.org/TR/2024/REC-WCAG22-20241212/","relationship":"accessibility-reference","usage":"audit-reference","evidence":["docs/reference/accessibility-policy.md"],"integrity":[]}, {"id":"web-features","name":"web-features","kind":"dataset","version":"3.32.0","status":"versioned-package","license":"Apache-2.0","url":"https://github.com/web-platform-dx/web-features","relationship":"compatibility-dataset","usage":"frozen-data","evidence":["integration-tests/compatibility-policy/expected.baseline-widely.json"],"integrity":[{"subject":"web-features@3.32.0","value":"sha512-PQBbTofqV8FtMP65oT9tLPjbN4FSB2dRdNxLM0A9j4bNifVpFhEP/ATXSMMJAqPPWb/pgUOh6B+98yzfNEVbNw=="},{"subject":"web-features@3.32.0/data.json","value":"sha256:58bc2056041c93e313c3a58658a8120f857cf4888d0592e6e4a9b0484748441e"}]} ]`); @@ -204,7 +207,7 @@ function validateManifest(manifest) { "w3c-candidate-recommendation-snapshot", "w3c-working-draft", ].includes(source.status), `${label}.status is invalid`); - assert(["Apache-2.0", "MIT", "MPL-2.0", "W3C-Document-License-2023", "W3C-Software-and-Document-License-2023", "W3C-Community-Final-Specification-Agreement"].includes(source.license), `${label}.license is invalid`); + assert(["Apache-2.0", "ISC", "MIT", "MPL-2.0", "W3C-Document-License-2023", "W3C-Software-and-Document-License-2023", "W3C-Community-Final-Specification-Agreement"].includes(source.license), `${label}.license is invalid`); assert([ "css-backend", "compatibility-dataset", @@ -212,8 +215,9 @@ function validateManifest(manifest) { "accessibility-reference", "css-specification", "benchmark-baseline", + "browser-certification", ].includes(source.relationship), `${label}.relationship is invalid`); - assert(["build-time", "frozen-data", "implemented-contract", "audit-reference", "benchmark-only"].includes(source.usage), `${label}.usage is invalid`); + assert(["build-time", "frozen-data", "implemented-contract", "audit-reference", "benchmark-only", "test-only"].includes(source.usage), `${label}.usage is invalid`); let url; try { @@ -324,8 +328,7 @@ function verifyAttributionScript(packageJson) { ); } -function verifyTailwindLock(lock, source) { - const contract = tailwindLockContract(source); +function verifyTailwindLock(lock, source, authority) { assert(lock.startsWith("lockfileVersion: '9.0'\n"), "pnpm lockfile version drifted"); const importers = lockSection(lock, "importers"); const packages = sectionEntries(lockSection(lock, "packages")); @@ -333,28 +336,67 @@ function verifyTailwindLock(lock, source) { const importerEntries = sectionEntries(importers); const rootImporter = oneEntry(importerEntries, ".", "importers"); - assert( - exactCount(rootImporter, contract.cliImporter) === 1 && - exactCount(rootImporter, contract.coreImporter) === 1, - "Tailwind root importer drifted", - ); - assert((importers.match(/^\s+'?@tailwindcss\/cli'?:$/gm) ?? []).length === 1, "Tailwind CLI must have exactly one importer edge"); - assert((importers.match(/^\s+tailwindcss:$/gm) ?? []).length === 1, "tailwindcss must have exactly one importer edge"); - - assert(oneEntry(packages, contract.cli, "packages") === contract.cliPackage, "Tailwind CLI package snapshot drifted"); - assert(oneEntry(packages, contract.core, "packages") === contract.corePackage, "tailwindcss package snapshot drifted"); - assert(oneEntry(snapshots, contract.cli, "snapshots") === contract.cliSnapshot, "Tailwind CLI dependency graph drifted"); - assert(oneEntry(snapshots, contract.core, "snapshots") === contract.coreSnapshot, "tailwindcss runtime snapshot drifted"); + const pins = new Map(); + for (const lane of authority.lanes) { + for (const [alias, packageName, version, integrity] of [ + [lane.packageAlias, lane.packageName, lane.version, lane.integrity], + [lane.cliPackageAlias, lane.cliPackageName, lane.cliVersion, lane.cliIntegrity], + ]) { + const key = `${packageName}@${version}`; + const existing = pins.get(alias); + assert(!existing || existing.key === key, `Tailwind alias ${alias} has conflicting pins`); + pins.set(alias, { alias, packageName, version, integrity, key }); + assert( + integrityValue(source, key) === integrity, + `standards provenance integrity drifted for ${key}`, + ); + } + } + for (const pin of pins.values()) { + const aliasKey = pin.alias.startsWith("@") ? `'${pin.alias}'` : pin.alias; + const specifier = pin.alias === pin.packageName + ? pin.version + : `npm:${pin.packageName}@${pin.version}`; + const resolvedVersion = pin.alias === pin.packageName + ? pin.version + : pin.packageName.startsWith("@") + ? `'${pin.packageName}@${pin.version}'` + : `${pin.packageName}@${pin.version}`; + const importer = ` ${aliasKey}:\n specifier: ${specifier}\n version: ${resolvedVersion}`; + assert(exactCount(rootImporter, importer) === 1, `Tailwind importer pin drifted for ${pin.alias}`); + } - for (const [prefix, expected] of [["@tailwindcss/cli@", contract.cli], ["tailwindcss@", contract.core]]) { - for (const [section, entries] of [["packages", packages], ["snapshots", snapshots]]) { - const versions = entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name); - assert(versions.length === 1 && versions[0] === expected, `${section} contains orphan or multiple ${prefix} entries`); + const uniquePackages = new Map([...pins.values()].map((pin) => [pin.key, pin])); + for (const pin of uniquePackages.values()) { + const packageEntry = oneEntry(packages, pin.key, "packages"); + assert( + packageEntry.includes(`resolution: {integrity: ${pin.integrity}}`), + `${pin.key} registry integrity drifted`, + ); + oneEntry(snapshots, pin.key, "snapshots"); + } + for (const lane of authority.lanes.filter((item) => item.cliPackageName === "@tailwindcss/cli")) { + const snapshot = oneEntry( + snapshots, + `${lane.cliPackageName}@${lane.cliVersion}`, + "snapshots", + ); + for (const dependency of [ + `'@tailwindcss/node': ${lane.cliVersion}`, + `'@tailwindcss/oxide': ${lane.cliVersion}`, + `tailwindcss: ${lane.version}`, + ]) { + assert(snapshot.includes(dependency), `${lane.id} CLI dependency graph drifted`); } } - for (const dependency of contract.dependencies) { - oneEntry(packages, dependency, "packages"); - oneEntry(snapshots, dependency, "snapshots"); + for (const [prefix, expected] of [ + ["@tailwindcss/cli@", ["@tailwindcss/cli@4.3.2", "@tailwindcss/cli@4.3.3"]], + ["tailwindcss@", ["tailwindcss@3.4.19", "tailwindcss@4.3.2", "tailwindcss@4.3.3"]], + ]) { + for (const [section, entries] of [["packages", packages], ["snapshots", snapshots]]) { + const actual = entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name).sort(); + assert(JSON.stringify(actual) === JSON.stringify(expected), `${section} ${prefix} set drifted`); + } } } @@ -362,13 +404,71 @@ function verifyTailwind(manifest) { const source = sourceById(manifest, "tailwindcss"); assert(source.usage === "benchmark-only", "Tailwind must remain benchmark-only"); const packageJson = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + const authority = JSON.parse( + readFileSync(resolve(root, "benchmarks", "benchmark-authority-v2", "oracle.json"), "utf8"), + ); + assert(authority.schemaVersion === 2, "Benchmark Authority must use schema 2"); + const primary = authority.lanes.find((lane) => lane.id === authority.primaryLane); + assert(primary && primary.version === source.version, "Tailwind provenance primary lane drifted"); verifyAttributionScript(packageJson); - assert(packageJson.devDependencies?.tailwindcss === source.version, "tailwindcss package version drifted"); - assert(packageJson.devDependencies?.["@tailwindcss/cli"] === source.version, "Tailwind CLI package version drifted"); + for (const lane of authority.lanes) { + for (const [alias, packageName, version] of [ + [lane.packageAlias, lane.packageName, lane.version], + [lane.cliPackageAlias, lane.cliPackageName, lane.cliVersion], + ]) { + const expected = alias === packageName ? version : `npm:${packageName}@${version}`; + assert(packageJson.devDependencies?.[alias] === expected, `${alias} package pin drifted`); + } + } const lockBytes = readFileSync(repositoryFile("pnpm-lock.yaml", "pnpm lockfile")); verifyPnpmLockIntegrity(lockBytes, source); const lock = lockBytes.toString("utf8").replaceAll("\r\n", "\n"); - verifyTailwindLock(lock, source); + verifyTailwindLock(lock, source, authority); +} + +function verifyBrowserCertificationToolchain(manifest) { + const packageJson = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + const lockBytes = readFileSync(repositoryFile("pnpm-lock.yaml", "pnpm lockfile")); + const lock = lockBytes.toString("utf8").replaceAll("\r\n", "\n"); + const importer = oneEntry(sectionEntries(lockSection(lock, "importers")), ".", "importers"); + const packages = sectionEntries(lockSection(lock, "packages")); + const snapshots = sectionEntries(lockSection(lock, "snapshots")); + for (const id of ["pixelmatch", "playwright", "pngjs"]) { + const source = sourceById(manifest, id); + assert(packageJson.devDependencies?.[id] === source.version, `${id} package pin drifted`); + verifyPnpmLockIntegrity(lockBytes, source); + assert( + importer.includes(` ${id}:\n specifier: ${source.version}\n version: ${source.version}`), + `${id} importer pin drifted`, + ); + const key = `${id}@${source.version}`; + const packageEntry = oneEntry(packages, key, "packages"); + assert( + packageEntry.includes(`resolution: {integrity: ${integrityValue(source, key)}}`), + `${key} registry integrity drifted`, + ); + oneEntry(snapshots, key, "snapshots"); + } + const playwright = sourceById(manifest, "playwright"); + const coreKey = `playwright-core@${playwright.version}`; + assert( + oneEntry(packages, coreKey, "packages").includes( + `resolution: {integrity: ${integrityValue(playwright, coreKey)}}`, + ), + `${coreKey} registry integrity drifted`, + ); + oneEntry(snapshots, coreKey, "snapshots"); + assert( + oneEntry(snapshots, `playwright@${playwright.version}`, "snapshots").includes( + `playwright-core: ${playwright.version}`, + ), + "Playwright dependency graph drifted", + ); + assert( + oneEntry(snapshots, `pixelmatch@${sourceById(manifest, "pixelmatch").version}`, "snapshots") + .includes(`pngjs: ${sourceById(manifest, "pngjs").version}`), + "pixelmatch dependency graph drifted", + ); } function exactCount(value, needle) { @@ -476,6 +576,9 @@ function verifyNoticeContent(manifest, notices) { "dtcg-resolver-2025.10": "## Implemented exchange reports", lightningcss: "## Build-time software", "mediaqueries-5": "## Accessibility and CSS references", + pixelmatch: "## Browser-certification software", + playwright: "## Browser-certification software", + pngjs: "## Browser-certification software", tailwindcss: "## Benchmark-only software", "wcag-2.2": "## Accessibility and CSS references", "web-features": "## Frozen compatibility data", @@ -540,22 +643,37 @@ function verifyIntegrationRegressions(manifest) { expectRejection("no-op attribution script", "command drifted", () => verifyAttributionScript(noOp)); const source = sourceById(manifest, "tailwindcss"); - const contract = tailwindLockContract(source); + const authority = JSON.parse( + readFileSync(resolve(root, "benchmarks", "benchmark-authority-v2", "oracle.json"), "utf8"), + ); const lock = readFileSync(resolve(root, "pnpm-lock.yaml"), "utf8").replaceAll("\r\n", "\n"); - const importerDrift = lock.replace(" specifier: 4.3.2", " specifier: 4.3.1"); - expectRejection("Tailwind importer drift", "root importer drifted", () => verifyTailwindLock(importerDrift, source)); + const importerDrift = lock.replace(" specifier: 4.3.3", " specifier: 4.3.1"); + expectRejection("Tailwind importer drift", "importer pin drifted", () => { + verifyTailwindLock(importerDrift, source, authority); + }); - const duplicatePackage = lock.replace(contract.cliPackage, `${contract.cliPackage}\n\n${contract.cliPackage}`); - expectRejection("duplicate Tailwind package", "exactly one", () => verifyTailwindLock(duplicatePackage, source)); + const packageEntries = sectionEntries(lockSection(lock, "packages")); + const cliPackage = oneEntry(packageEntries, "@tailwindcss/cli@4.3.3", "packages"); + const duplicatePackage = lock.replace(cliPackage, `${cliPackage}\n\n${cliPackage}`); + expectRejection("duplicate Tailwind package", "exactly one", () => { + verifyTailwindLock(duplicatePackage, source, authority); + }); - const orphanSnapshot = lock.replace(`\n${contract.coreSnapshot}\n`, "\n"); - expectRejection("orphan Tailwind package", "exactly one", () => verifyTailwindLock(orphanSnapshot, source)); + const snapshotEntries = sectionEntries(lockSection(lock, "snapshots")); + const coreSnapshot = oneEntry(snapshotEntries, "tailwindcss@4.3.3", "snapshots"); + const orphanSnapshot = lock.replace(`\n${coreSnapshot}\n`, "\n"); + expectRejection("orphan Tailwind package", "exactly one", () => { + verifyTailwindLock(orphanSnapshot, source, authority); + }); + const cliSnapshot = oneEntry(snapshotEntries, "@tailwindcss/cli@4.3.3", "snapshots"); const graphDrift = lock.replace( - contract.cliSnapshot, - contract.cliSnapshot.replace(`tailwindcss: ${source.version}`, "tailwindcss: 4.3.1"), + cliSnapshot, + cliSnapshot.replace("tailwindcss: 4.3.3", "tailwindcss: 4.3.1"), ); - expectRejection("Tailwind dependency graph drift", "dependency graph drifted", () => verifyTailwindLock(graphDrift, source)); + expectRejection("Tailwind dependency graph drift", "dependency graph drifted", () => { + verifyTailwindLock(graphDrift, source, authority); + }); const transitiveDrift = lock.replace(" lightningcss: 1.32.0", " lightningcss: 1.31.0"); expectRejection("Tailwind transitive lock drift", "SHA-256 drifted", () => { @@ -638,6 +756,7 @@ function main() { verifyDtcg(manifest); verifyAccessibilityReferences(manifest); verifyTailwind(manifest); + verifyBrowserCertificationToolchain(manifest); verifyNotices(manifest); verifyRepositoryWiring(); console.log("standards provenance gate: pass"); diff --git a/scripts/check-tailwind-matrix.mjs b/scripts/check-tailwind-matrix.mjs index 07a09b0..14b500f 100644 --- a/scripts/check-tailwind-matrix.mjs +++ b/scripts/check-tailwind-matrix.mjs @@ -1,11 +1,17 @@ +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { + corpusMetrics, + loadAuthority, + materializeCorpus, + repositoryRoot, +} from "./benchmark-authority-v2.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const matrixPath = resolve(root, "docs/benchmarks/tailwind-v4-competitive-matrix.json"); +const matrixPath = resolve(repositoryRoot, "docs/benchmarks/tailwind-v4-competitive-matrix.json"); const matrix = JSON.parse(readFileSync(matrixPath, "utf8")); +const authority = loadAuthority(); function fail(message) { throw new Error(`Tailwind competitive matrix: ${message}`); @@ -18,33 +24,37 @@ function array(value, label) { return value; } -if (matrix.schemaVersion !== 1) fail("schemaVersion must be 1"); -if (matrix.baseline?.product !== "Tailwind CSS" || matrix.baseline?.version !== "4.3.2") { - fail("baseline must pin Tailwind CSS 4.3.2"); +if (matrix.schemaVersion !== 2) fail("schemaVersion must be 2"); +if (matrix.baseline?.product !== "Tailwind CSS" || matrix.baseline?.license !== "MIT") { + fail("baseline identity must be Tailwind CSS/MIT"); } -if (matrix.baseline?.license !== "MIT") fail("baseline license must be MIT"); -const packagePath = resolve(root, matrix.baseline.packageManifest); -const fixturePath = resolve(root, matrix.baseline.fixture); -for (const [path, expected, label] of [ - [packagePath, matrix.baseline.packageManifestSha256, "package manifest"], - [fixturePath, matrix.baseline.fixtureSha256, "fixture"], -]) { - if (!existsSync(path)) fail(`${label} is missing`); - const actual = sha256(path); - if (actual !== expected) fail(`${label} hash drifted: expected ${expected}, found ${actual}`); +if (matrix.baseline.authority !== "benchmarks/benchmark-authority-v2/oracle.json") { + fail("baseline must delegate competitor identity to Benchmark Authority v2"); +} +if (matrix.baseline.primaryLane !== authority.primaryLane) fail("primary lane drifted"); +const primaryLane = authority.lanes.find((lane) => lane.id === authority.primaryLane); +if (!primaryLane || matrix.baseline.version !== primaryLane.version) fail("primary version drifted"); +const packagePath = resolve(repositoryRoot, matrix.baseline.packageManifest); +const fixturePath = resolve(repositoryRoot, matrix.baseline.fixture); +if (sha256(packagePath) !== matrix.baseline.packageManifestSha256) fail("package manifest hash drifted"); +const installed = JSON.parse(readFileSync(packagePath, "utf8")); +if (installed.name !== "tailwindcss" || installed.version !== primaryLane.version) { + fail("installed primary Tailwind package drifted"); } -const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); -if (packageJson.name !== "tailwindcss" || packageJson.version !== matrix.baseline.version) { - fail("installed Tailwind package identity drifted"); +const fixtureMetrics = corpusMetrics(readFileSync(fixturePath, "utf8").replaceAll("\r\n", "\n")); +for (const [field, expected] of [ + ["sha256", matrix.baseline.fixtureSha256], + ["classAttributeCount", matrix.baseline.fixtureClassAttributes], + ["utilityOccurrences", matrix.baseline.fixtureUtilityOccurrences], + ["uniqueUtilityTokens", matrix.baseline.fixtureUniqueUtilities], +]) { + if (fixtureMetrics[field] !== expected) fail(`fixture ${field} drifted`); } -const fixture = readFileSync(fixturePath, "utf8"); -const classValues = [...fixture.matchAll(/class="([^"]+)"/g)].map((match) => match[1]); -const utilities = classValues.flatMap((value) => value.trim().split(/\s+/u)); -if (classValues.length !== matrix.baseline.fixtureClassAttributes) fail("fixture class count drifted"); -if (utilities.length !== matrix.baseline.fixtureUtilityOccurrences) fail("fixture utility count drifted"); -if (new Set(utilities).size !== matrix.baseline.fixtureUniqueUtilities) { - fail("fixture unique utility count drifted"); +const medium = authority.corpora.find((corpus) => corpus.id === "medium"); +if (JSON.stringify(corpusMetrics(materializeCorpus(medium))) !== JSON.stringify(medium.expected)) { + fail("matrix fixture is not the authority medium corpus"); } + const statuses = new Set([ "matched", "pliego-differentiator", @@ -53,20 +63,18 @@ const statuses = new Set([ "open-gap", ]); const impacts = new Set(["keep-green", "blocking", "should", "non-blocking", "post-0.1"]); -if (Object.keys(matrix.statuses ?? {}).sort().join("\n") !== [...statuses].sort().join("\n")) { +if (JSON.stringify(Object.keys(matrix.statuses).sort()) !== JSON.stringify([...statuses].sort())) { fail("status definitions drifted"); } const sourceIds = new Set(); for (const source of array(matrix.sources, "sources")) { if (sourceIds.has(source.id)) fail(`duplicate source ${source.id}`); sourceIds.add(source.id); - if (source.kind === "local-evidence" || source.kind === "local-contract") { - if (!existsSync(resolve(root, source.path))) fail(`source ${source.id} path is missing`); + if (new Set(["local-evidence", "historical-evidence", "local-contract"]).has(source.kind)) { + if (!existsSync(resolve(repositoryRoot, source.path))) fail(`source ${source.id} is missing`); } else if (source.kind === "upstream-doc") { - if (!/^https:\/\/tailwindcss\.com\/docs\//u.test(source.url ?? "")) { - fail(`source ${source.id} is not a Tailwind documentation URL`); - } - if (source.versionObserved !== "v4.3") fail(`source ${source.id} version is not v4.3`); + if (!/^https:\/\/tailwindcss\.com\/docs\//u.test(source.url ?? "")) fail(`${source.id} is not an upstream docs URL`); + if (source.versionObserved !== "v4.3") fail(`${source.id} upstream version drifted`); } else { fail(`source ${source.id} has unknown kind`); } @@ -74,35 +82,39 @@ for (const source of array(matrix.sources, "sources")) { const dimensionIds = new Set(); const counts = Object.fromEntries([...statuses].map((status) => [status, 0])); for (const dimension of array(matrix.dimensions, "dimensions")) { - if (!/^[a-z][a-z0-9-]*$/u.test(dimension.id ?? "")) fail("dimension id is not canonical"); - if (dimensionIds.has(dimension.id)) fail(`duplicate dimension ${dimension.id}`); + if (!/^[a-z][a-z0-9-]*$/u.test(dimension.id ?? "") || dimensionIds.has(dimension.id)) { + fail(`invalid or duplicate dimension ${dimension.id}`); + } dimensionIds.add(dimension.id); if (!statuses.has(dimension.status)) fail(`${dimension.id} has unknown status`); if (!impacts.has(dimension.releaseImpact)) fail(`${dimension.id} has unknown releaseImpact`); for (const field of ["area", "tailwind", "pliego", "nextAction"]) { - if (typeof dimension[field] !== "string" || dimension[field].trim() === "") { - fail(`${dimension.id}.${field} is empty`); - } + if (typeof dimension[field] !== "string" || !dimension[field].trim()) fail(`${dimension.id}.${field} is empty`); } for (const evidence of array(dimension.evidence, `${dimension.id}.evidence`)) { if (!sourceIds.has(evidence)) fail(`${dimension.id} references unknown evidence ${evidence}`); } counts[dimension.status] += 1; } -const expectedCounts = { - matched: 5, - "pliego-differentiator": 6, - "tailwind-advantage": 5, - "different-by-design": 3, - "open-gap": 3, -}; -if (JSON.stringify(counts) !== JSON.stringify(expectedCounts)) { - fail(`status counts drifted: ${JSON.stringify(counts)}`); +if (dimensionIds.size !== 22) fail(`expected 22 competitive dimensions, found ${dimensionIds.size}`); +for (const [status, count] of Object.entries(counts)) { + if (count < 1) fail(`matrix has no ${status} dimension`); } -const human = readFileSync(resolve(root, "docs/benchmarks/tailwind-v4-competitive-matrix.md"), "utf8"); -for (const marker of ["Tailwind CSS v4.3.2", "Blocking `0.1.0`", "Explicitly post-0.1"]) { - if (!human.includes(marker)) fail(`human matrix is missing ${marker}`); +for (const id of ["build-latency-fixture", "payload-fixture"]) { + const dimension = matrix.dimensions.find((item) => item.id === id); + if (dimension?.status !== "open-gap" || dimension.releaseImpact !== "blocking") { + fail(`${id} must remain blocking until clean schema-2 evidence exists`); + } } + +const renderer = spawnSync(process.execPath, ["scripts/render-tailwind-matrix.mjs", "--check"], { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, +}); +if (renderer.error) throw renderer.error; +if (renderer.status !== 0) fail(renderer.stderr.trim() || "generated Markdown check failed"); + process.stdout.write( - `${JSON.stringify({ schemaVersion: 1, baseline: matrix.baseline.version, dimensions: matrix.dimensions.length, counts }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: 2, baseline: primaryLane.version, dimensions: dimensionIds.size, counts }, null, 2)}\n`, ); diff --git a/scripts/check-vscode-client.mjs b/scripts/check-vscode-client.mjs index 9908320..12eddc7 100644 --- a/scripts/check-vscode-client.mjs +++ b/scripts/check-vscode-client.mjs @@ -7,6 +7,7 @@ const ROOT = resolve(import.meta.dirname, ".."); const EXTENSION = resolve(ROOT, "editors", "vscode"); const VSIX = resolve(ROOT, "target", "pliegocss-vscode-0.0.0.vsix"); const pnpmScript = process.env.npm_execpath; +const pnpmScriptUsesNode = pnpmScript && /\.(?:cjs|mjs|js)$/iu.test(pnpmScript); const fallbackPnpm = [ "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js", "exec", "--yes", "--package=pnpm@10.14.0", "--", "pnpm", @@ -17,8 +18,10 @@ function fail(message) { } function run(args, cwd = ROOT) { - const command = pnpmScript ? process.execPath : process.execPath; - const commandArgs = pnpmScript ? [pnpmScript, ...args] : [...fallbackPnpm, ...args]; + const command = pnpmScript && !pnpmScriptUsesNode ? pnpmScript : process.execPath; + const commandArgs = pnpmScript + ? (pnpmScriptUsesNode ? [pnpmScript, ...args] : args) + : [...fallbackPnpm, ...args]; const result = spawnSync(command, commandArgs, { cwd, encoding: "utf8", @@ -54,9 +57,10 @@ if (manifest.capabilities?.untrustedWorkspaces?.supported !== false) { fail("untrusted workspaces must fail closed"); } const bundle = readFileSync(resolve(EXTENSION, "dist", "extension.js"), "utf8"); -for (const contract of ["pliego-css-lsp", "pliego-cssc", "onDidChangeConfiguration"]) { +for (const contract of ["pliego-css-lsp", "onDidChangeConfiguration"]) { if (!bundle.includes(contract)) fail(`bundle is missing ${contract}`); } +if (bundle.includes("--pliego-cssc")) fail("VS Code client still delegates to a compiler path"); process.stdout.write( `${JSON.stringify({ diff --git a/scripts/external-adoption-v1.mjs b/scripts/external-adoption-v1.mjs new file mode 100644 index 0000000..19d1ef7 --- /dev/null +++ b/scripts/external-adoption-v1.mjs @@ -0,0 +1,756 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +const DATE = /^\d{4}-\d{2}-\d{2}$/u; +const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u; +const DIGEST = /^sha256:[0-9a-f]{64}$/u; +const HEX_DIGEST = /^[0-9a-f]{64}$/u; +const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +export class ExternalAdoptionError extends Error { + constructor(message) { + super(message); + this.name = "ExternalAdoptionError"; + } +} + +function fail(message) { + throw new ExternalAdoptionError(message); +} + +function object(value, role) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${role} must be an object`); + } + return value; +} + +function exactKeys(value, keys, role) { + object(value, role); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(`${role} fields drifted: ${actual.join(", ")}`); + } +} + +function nonEmpty(value, role) { + if (typeof value !== "string" || value.trim() === "") fail(`${role} must be a non-empty string`); + return value; +} + +function canonicalId(value, role) { + if (typeof value !== "string" || !ID.test(value)) fail(`${role} is not a canonical ID`); + return value; +} + +function strings(value, role, { nonEmptyArray = true } = {}) { + if ( + !Array.isArray(value) || + (nonEmptyArray && value.length === 0) || + value.some((entry) => typeof entry !== "string" || entry.trim() === "") + ) { + fail(`${role} must be ${nonEmptyArray ? "a non-empty " : "an "}array of non-empty strings`); + } + if (new Set(value).size !== value.length) fail(`${role} contains duplicates`); + return value; +} + +function instant(value, role) { + if (typeof value !== "string" || !INSTANT.test(value) || Number.isNaN(Date.parse(value))) { + fail(`${role} must be a canonical UTC second instant`); + } + return Date.parse(value); +} + +function digest(value, role) { + if (typeof value !== "string" || !DIGEST.test(value)) fail(`${role} is not a canonical SHA-256 digest`); + if (value === `sha256:${"0".repeat(64)}`) fail(`${role} cannot be the zero digest`); +} + +function repositoryPath(root, value, role) { + if ( + typeof value !== "string" || + value === "" || + isAbsolute(value) || + value.includes("\\") || + value.split("/").includes("..") + ) { + fail(`${role} must be a portable repository-relative path`); + } + const absolute = resolve(root, value); + const child = relative(root, absolute); + if (!child || child.startsWith("..") || isAbsolute(child)) fail(`${role} escapes the repository`); + if (!existsSync(absolute)) fail(`${role} does not exist: ${value}`); + return absolute; +} + +function hashedFile(root, value, role, verifyFiles) { + exactKeys(value, ["path", "sha256"], role); + if (!HEX_DIGEST.test(value.sha256)) fail(`${role}.sha256 is not canonical`); + if (!verifyFiles) return; + const path = repositoryPath(root, value.path, `${role}.path`); + const actual = createHash("sha256").update(readFileSync(path)).digest("hex"); + if (actual !== value.sha256) fail(`${role} hash drifted for ${value.path}`); +} + +function consent(value, role, requiredStatus) { + exactKeys(value, ["status", "recordedAt", "researchUse", "publicRedactedRecord"], role); + if (value.status !== requiredStatus) fail(`${role}.status must be ${requiredStatus}`); + instant(value.recordedAt, `${role}.recordedAt`); + if (value.researchUse !== true || value.publicRedactedRecord !== true) { + fail(`${role} must permit research use and the public redacted record`); + } +} + +function redaction(value, role) { + exactKeys(value, ["status", "containsPersonalData"], role); + if (value.status !== "reviewed" || value.containsPersonalData !== false) { + fail(`${role} must be reviewed and contain no personal data`); + } +} + +function rejectSensitiveRecord(value, role) { + const text = JSON.stringify(value); + for (const [pattern, label] of [ + [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu, "email address"], + [/-----BEGIN [A-Z ]+PRIVATE KEY-----/u, "private key"], + [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/u, "GitHub token"], + [/\bAKIA[0-9A-Z]{16}\b/u, "AWS access key"], + [/\b[A-Za-z]:\\\\[^"]+/u, "absolute Windows path"], + ]) { + if (pattern.test(text)) fail(`${role} appears to contain a ${label}`); + } +} + +function source(value, role) { + exactKeys(value, ["custodyRef", "sha256"], role); + nonEmpty(value.custodyRef, `${role}.custodyRef`); + digest(value.sha256, `${role}.sha256`); +} + +function review(value, role, requiredStatus) { + exactKeys(value, ["reviewer", "reviewedAt", "status"], role); + canonicalId(value.reviewer, `${role}.reviewer`); + instant(value.reviewedAt, `${role}.reviewedAt`); + if (value.status !== requiredStatus) fail(`${role}.status must be ${requiredStatus}`); +} + +function relationship(value, authority, organizationId, role) { + if (!authority.eligibility.allowedRelationships.includes(value)) { + fail(`${role} relationship is not external`); + } + if (authority.eligibility.excludedOrganizationIds.includes(organizationId)) { + fail(`${role} uses an excluded organization`); + } +} + +function documentHeader(value, kind, studyId, role) { + exactKeys(value, ["schemaVersion", "kind", "studyId", "records"], role); + if (value.schemaVersion !== 1 || value.kind !== kind || value.studyId !== studyId) { + fail(`${role} header drifted`); + } + if (!Array.isArray(value.records)) fail(`${role}.records must be an array`); +} + +function validateInterviews(value, authority) { + documentHeader( + value, + "pliegocss-external-adoption-interviews", + authority.studyId, + "interviews", + ); + const ids = new Set(); + const participants = new Set(); + const sourceDigests = new Set(); + for (const [index, entry] of value.records.entries()) { + const role = `interviews.records[${index}]`; + exactKeys( + entry, + [ + "id", + "participantId", + "organizationId", + "relationship", + "conductedAt", + "consent", + "redaction", + "source", + "review", + "findings", + "relatedIncidentIds", + "relatedPilotIds", + ], + role, + ); + rejectSensitiveRecord(entry, role); + canonicalId(entry.id, `${role}.id`); + canonicalId(entry.participantId, `${role}.participantId`); + canonicalId(entry.organizationId, `${role}.organizationId`); + if (ids.has(entry.id)) fail(`duplicate interview ID ${entry.id}`); + if (participants.has(entry.participantId)) fail(`duplicate interview participant ${entry.participantId}`); + ids.add(entry.id); + participants.add(entry.participantId); + relationship(entry.relationship, authority, entry.organizationId, role); + const conductedAt = instant(entry.conductedAt, `${role}.conductedAt`); + consent(entry.consent, `${role}.consent`, authority.eligibility.requiredConsentStatus); + if (Date.parse(entry.consent.recordedAt) > conductedAt) { + fail(`${role}.consent was recorded after the interview`); + } + redaction(entry.redaction, `${role}.redaction`); + source(entry.source, `${role}.source`); + if (entry.source.custodyRef !== `private:g7/interviews/${entry.id}`) { + fail(`${role}.source.custodyRef does not match its interview ID`); + } + if (sourceDigests.has(entry.source.sha256)) fail(`${role} duplicates another interview source`); + sourceDigests.add(entry.source.sha256); + review(entry.review, `${role}.review`, authority.eligibility.requiredReviewStatus); + if (entry.review.reviewer === entry.participantId) fail(`${role} is self-reviewed`); + if (Date.parse(entry.review.reviewedAt) < conductedAt) fail(`${role}.review predates the interview`); + strings(entry.findings, `${role}.findings`); + strings(entry.relatedIncidentIds, `${role}.relatedIncidentIds`, { nonEmptyArray: false }); + strings(entry.relatedPilotIds, `${role}.relatedPilotIds`, { nonEmptyArray: false }); + } + return { count: value.records.length, ids }; +} + +function validateIncidents(value, authority) { + documentHeader( + value, + "pliegocss-external-adoption-incidents", + authority.studyId, + "incidents", + ); + const ids = new Set(); + const sourceDigests = new Set(); + for (const [index, entry] of value.records.entries()) { + const role = `incidents.records[${index}]`; + exactKeys( + entry, + [ + "id", + "projectId", + "organizationId", + "relationship", + "occurredAt", + "framework", + "category", + "severity", + "synthetic", + "provenance", + "consent", + "redaction", + "expectedDiagnosis", + "allowedAmbiguity", + "source", + "review", + ], + role, + ); + rejectSensitiveRecord(entry, role); + canonicalId(entry.id, `${role}.id`); + canonicalId(entry.projectId, `${role}.projectId`); + canonicalId(entry.organizationId, `${role}.organizationId`); + if (ids.has(entry.id)) fail(`duplicate incident ID ${entry.id}`); + ids.add(entry.id); + relationship(entry.relationship, authority, entry.organizationId, role); + const occurredAt = instant(entry.occurredAt, `${role}.occurredAt`); + nonEmpty(entry.framework, `${role}.framework`); + canonicalId(entry.category, `${role}.category`); + if (!["low", "medium", "high", "critical"].includes(entry.severity)) { + fail(`${role}.severity is invalid`); + } + if (entry.synthetic !== false || entry.provenance !== "external-project-observation") { + fail(`${role} is not a real external incident`); + } + consent(entry.consent, `${role}.consent`, authority.eligibility.requiredConsentStatus); + redaction(entry.redaction, `${role}.redaction`); + nonEmpty(entry.expectedDiagnosis, `${role}.expectedDiagnosis`); + nonEmpty(entry.allowedAmbiguity, `${role}.allowedAmbiguity`); + source(entry.source, `${role}.source`); + if (entry.source.custodyRef !== `private:g7/incidents/${entry.id}`) { + fail(`${role}.source.custodyRef does not match its incident ID`); + } + if (sourceDigests.has(entry.source.sha256)) fail(`${role} duplicates another incident source`); + sourceDigests.add(entry.source.sha256); + review(entry.review, `${role}.review`, authority.eligibility.requiredReviewStatus); + if (Date.parse(entry.review.reviewedAt) < occurredAt) fail(`${role}.review predates the incident`); + } + return { count: value.records.length, ids }; +} + +function validatePilots(value, authority) { + documentHeader(value, "pliegocss-external-adoption-pilots", authority.studyId, "pilots"); + const ids = new Set(); + const projects = new Set(); + const organizations = new Set(); + const sourceDigests = new Set(); + for (const [index, entry] of value.records.entries()) { + const role = `pilots.records[${index}]`; + exactKeys( + entry, + [ + "id", + "projectId", + "organizationId", + "repositoryOwner", + "relationship", + "adapter", + "tailwindProfile", + "startedAt", + "completedAt", + "status", + "maintainerIds", + "consent", + "redaction", + "source", + "outcomes", + "review", + ], + role, + ); + rejectSensitiveRecord(entry, role); + canonicalId(entry.id, `${role}.id`); + canonicalId(entry.projectId, `${role}.projectId`); + canonicalId(entry.organizationId, `${role}.organizationId`); + canonicalId(entry.repositoryOwner, `${role}.repositoryOwner`); + if (ids.has(entry.id)) fail(`duplicate pilot ID ${entry.id}`); + if (projects.has(entry.projectId)) fail(`duplicate pilot project ${entry.projectId}`); + if (authority.eligibility.excludedRepositoryOwners.includes(entry.repositoryOwner)) { + fail(`${role} is owned by an excluded repository owner`); + } + ids.add(entry.id); + projects.add(entry.projectId); + organizations.add(entry.organizationId); + relationship(entry.relationship, authority, entry.organizationId, role); + if (!["html", "vite", "pliegors"].includes(entry.adapter)) fail(`${role}.adapter is unsupported`); + if (!["tailwind-v3-lts", "tailwind-v4-current"].includes(entry.tailwindProfile)) { + fail(`${role}.tailwindProfile is unsupported`); + } + const started = instant(entry.startedAt, `${role}.startedAt`); + const completed = instant(entry.completedAt, `${role}.completedAt`); + if (completed < started || entry.status !== "completed") fail(`${role} is not a completed pilot`); + strings(entry.maintainerIds, `${role}.maintainerIds`); + consent(entry.consent, `${role}.consent`, authority.eligibility.requiredConsentStatus); + if (Date.parse(entry.consent.recordedAt) > started) { + fail(`${role}.consent was recorded after the pilot started`); + } + redaction(entry.redaction, `${role}.redaction`); + exactKeys(entry.source, ["url", "commit", "sha256"], `${role}.source`); + if (typeof entry.source.url !== "string" || !entry.source.url.startsWith("https://")) { + fail(`${role}.source.url must use HTTPS`); + } + if ( + !/^[0-9a-f]{40}$/u.test(entry.source.commit) || + entry.source.commit === "0".repeat(40) + ) { + fail(`${role}.source.commit is not canonical`); + } + digest(entry.source.sha256, `${role}.source.sha256`); + if (sourceDigests.has(entry.source.sha256)) fail(`${role} duplicates another pilot source`); + sourceDigests.add(entry.source.sha256); + let sourceUrl; + try { + sourceUrl = new URL(entry.source.url); + } catch { + fail(`${role}.source.url is invalid`); + } + if (sourceUrl.hostname === "github.com") { + const sourceOwner = sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLowerCase(); + if (sourceOwner !== entry.repositoryOwner) { + fail(`${role}.repositoryOwner does not match the GitHub evidence URL`); + } + } + exactKeys( + entry.outcomes, + [ + "adoptionCompleted", + "rollbackVerified", + "browserEquivalenceVerified", + "blockingFailures", + ], + `${role}.outcomes`, + ); + if ( + entry.outcomes.adoptionCompleted !== true || + entry.outcomes.rollbackVerified !== true || + entry.outcomes.browserEquivalenceVerified !== true || + entry.outcomes.blockingFailures !== 0 + ) { + fail(`${role}.outcomes do not satisfy the pilot protocol`); + } + review(entry.review, `${role}.review`, authority.eligibility.requiredReviewStatus); + if (entry.maintainerIds.includes(entry.review.reviewer)) fail(`${role} is self-reviewed`); + if (Date.parse(entry.review.reviewedAt) < completed) fail(`${role}.review predates completion`); + } + return { count: value.records.length, ids, projects, organizations }; +} + +function validatePolicy(value, coexistence, { root, verifyFiles }) { + exactKeys( + value, + [ + "schemaVersion", + "kind", + "status", + "releaseLine", + "frozenAt", + "protocol", + "supportedTailwindProfiles", + "supportedAdapters", + "migration", + "verification", + "supportTiers", + "claimBoundary", + ], + "adapterSupportPolicy", + ); + if ( + value.schemaVersion !== 1 || + value.kind !== "pliegocss-adapter-support-policy" || + value.status !== "frozen-for-0.1-promotion" || + value.releaseLine !== "0.1.x" || + !DATE.test(value.frozenAt) + ) { + fail("adapterSupportPolicy header drifted"); + } + exactKeys(value.protocol, ["name", "authoritySchema", "migrationInventorySchema"], "adapterSupportPolicy.protocol"); + if ( + value.protocol.name !== "static-complete-class-group-coexistence" || + value.protocol.authoritySchema !== 1 || + value.protocol.migrationInventorySchema !== 1 + ) { + fail("adapterSupportPolicy protocol drifted"); + } + if (!Array.isArray(value.supportedTailwindProfiles) || value.supportedTailwindProfiles.length !== 2) { + fail("adapterSupportPolicy must freeze exactly two Tailwind profiles"); + } + const profiles = new Map(); + for (const [index, profile] of value.supportedTailwindProfiles.entries()) { + exactKeys(profile, ["id", "certifiedVersion", "sourceContract"], `adapterSupportPolicy.supportedTailwindProfiles[${index}]`); + profiles.set(profile.id, profile); + } + if ( + profiles.get("tailwind-v3-lts")?.certifiedVersion !== "3.4.19" || + profiles.get("tailwind-v4-current")?.certifiedVersion !== "4.3.3" + ) { + fail("adapterSupportPolicy Tailwind versions drifted"); + } + if (!Array.isArray(value.supportedAdapters) || value.supportedAdapters.length !== 3) { + fail("adapterSupportPolicy must freeze exactly three adapters"); + } + const adapters = new Map(); + for (const [index, adapter] of value.supportedAdapters.entries()) { + exactKeys(adapter, ["id", "supportTier", "inputContract", "version"], `adapterSupportPolicy.supportedAdapters[${index}]`); + if (adapter.supportTier !== "certified") fail(`${adapter.id} is not certified`); + adapters.set(adapter.id, adapter); + } + if ( + adapters.get("html")?.version !== null || + adapters.get("html")?.inputContract !== "complete-static-html-document" || + adapters.get("vite")?.version !== "8.1.5" || + adapters.get("vite")?.inputContract !== "vite-production-build" || + adapters.get("pliegors")?.version !== "pliego-dom=0.0.2;pliego-ssg=0.0.2" || + adapters.get("pliegors")?.inputContract !== + "rendered-static-html-plus-pinned-framework-browser-replay" + ) { + fail("adapterSupportPolicy adapter versions drifted"); + } + exactKeys( + value.migration, + [ + "mode", + "tailwindRuntimeRetained", + "applyUnit", + "rollback", + "dynamicClasses", + "arbitraryConfigOrPluginExecution", + "tailwindRemoval", + ], + "adapterSupportPolicy.migration", + ); + if ( + value.migration.mode !== "coexistence" || + value.migration.tailwindRuntimeRetained !== true || + value.migration.applyUnit !== "complete-literal-class-group" || + value.migration.rollback !== "exact-source-bytes" || + value.migration.dynamicClasses !== "unsupported-fail-closed" || + value.migration.arbitraryConfigOrPluginExecution !== "unsupported-fail-closed" || + value.migration.tailwindRemoval !== "not-certified" + ) { + fail("adapterSupportPolicy migration boundary drifted"); + } + exactKeys( + value.verification, + ["requiredHosts", "requiredComparisons", "maximumLayoutGeometryDeltaCssPx"], + "adapterSupportPolicy.verification", + ); + strings(value.verification.requiredHosts, "adapterSupportPolicy.verification.requiredHosts"); + strings(value.verification.requiredComparisons, "adapterSupportPolicy.verification.requiredComparisons"); + if ( + JSON.stringify(value.verification.requiredComparisons) !== + JSON.stringify(["dom", "aria", "computed-style", "layout-geometry", "exact-rollback"]) || + value.verification.maximumLayoutGeometryDeltaCssPx !== 0.1 + ) { + fail("adapterSupportPolicy geometry tolerance drifted"); + } + exactKeys(value.supportTiers, ["certified", "best-effort", "unsupported"], "adapterSupportPolicy.supportTiers"); + for (const [key, description] of Object.entries(value.supportTiers)) { + nonEmpty(description, `adapterSupportPolicy.supportTiers.${key}`); + } + nonEmpty(value.claimBoundary, "adapterSupportPolicy.claimBoundary"); + + if ( + !coexistence || + coexistence.schemaVersion !== 1 || + coexistence.kind !== "pliegocss-adapter-coexistence-certification" || + coexistence.status !== "active-contract" + ) { + fail("adapter coexistence authority is unavailable or incompatible"); + } + const certifiedProfiles = new Map( + coexistence.tailwindProfiles.map((profile) => [profile.id, profile.version]), + ); + for (const profile of value.supportedTailwindProfiles) { + const certified = coexistence.tailwindProfiles.find((entry) => entry.id === profile.id); + if ( + certifiedProfiles.get(profile.id) !== profile.certifiedVersion || + certified?.sourceContract !== profile.sourceContract + ) { + fail(`adapterSupportPolicy ${profile.id} does not match the G6 authority`); + } + } + const certifiedAdapters = new Map(coexistence.adapters.map((adapter) => [adapter.id, adapter])); + if (certifiedAdapters.get("vite")?.version !== adapters.get("vite")?.version) { + fail("adapterSupportPolicy Vite version does not match the G6 authority"); + } + if ( + JSON.stringify(coexistence.hosts.map((host) => host.id)) !== + JSON.stringify(value.verification.requiredHosts) || + coexistence.comparison.maximumLayoutGeometryDeltaCssPx !== + value.verification.maximumLayoutGeometryDeltaCssPx + ) { + fail("adapterSupportPolicy verification matrix does not match the G6 authority"); + } + if ( + coexistence.migration.tailwindRuntimeRetained !== value.migration.tailwindRuntimeRetained || + coexistence.migration.dynamicClassPolicy !== "reject" || + coexistence.migration.rollback !== "restore-exact-source-bytes" + ) { + fail("adapterSupportPolicy migration semantics do not match the G6 authority"); + } + if (verifyFiles) { + const manifest = readFileSync( + resolve(root, "integration-tests", "pliegors-dev-loop", "Cargo.toml"), + "utf8", + ); + for (const dependency of ["pliego-dom", "pliego-ssg"]) { + if (!new RegExp(`^${dependency} = "=0\\.0\\.2"$`, "mu").test(manifest)) { + fail(`adapterSupportPolicy PliegoRS pin drifted from ${dependency}`); + } + } + } +} + +function validateAuthority(authority, { root, verifyFiles }) { + exactKeys( + authority, + [ + "schemaVersion", + "status", + "kind", + "studyId", + "recruitment", + "evidence", + "adapterSupportPolicy", + "thresholds", + "eligibility", + "requiredCoverage", + "claimBoundary", + ], + "authority", + ); + if ( + authority.schemaVersion !== 1 || + authority.status !== "active-contract" || + authority.kind !== "pliegocss-external-adoption-authority" || + authority.studyId !== "g7-external-adoption-0.1" + ) { + fail("authority header drifted"); + } + exactKeys( + authority.recruitment, + [ + "status", + "openedAt", + "publicCohortUrl", + "privateContact", + "privacyNotice", + "publicApplicationPolicy", + ], + "authority.recruitment", + ); + instant(authority.recruitment.openedAt, "authority.recruitment.openedAt"); + if ( + authority.recruitment.status !== "open" || + authority.recruitment.publicCohortUrl !== + "https://github.com/celiumsai/pliegocss/issues/7" || + authority.recruitment.privateContact !== + "mailto:hello@pliegocss.dev?subject=G7%20external%20adoption" || + authority.recruitment.privacyNotice !== "https://pliegocss.dev/legal/privacy/" || + authority.recruitment.publicApplicationPolicy !== + "recruitment-only-no-private-data" + ) { + fail("authority recruitment contract drifted"); + } + exactKeys(authority.evidence, ["interviews", "incidents", "pilots"], "authority.evidence"); + for (const name of ["interviews", "incidents", "pilots"]) { + hashedFile(root, authority.evidence[name], `authority.evidence.${name}`, verifyFiles); + } + hashedFile(root, authority.adapterSupportPolicy, "authority.adapterSupportPolicy", verifyFiles); + exactKeys(authority.thresholds, ["interviews", "incidents", "pilots"], "authority.thresholds"); + exactKeys(authority.thresholds.interviews, ["minimumQualified", "targetMaximum"], "authority.thresholds.interviews"); + exactKeys(authority.thresholds.incidents, ["minimumQualified"], "authority.thresholds.incidents"); + exactKeys( + authority.thresholds.pilots, + ["minimumQualified", "targetMaximum", "minimumIndependentProjects", "minimumIndependentOrganizations"], + "authority.thresholds.pilots", + ); + if ( + authority.thresholds.interviews.minimumQualified !== 10 || + authority.thresholds.interviews.targetMaximum !== 15 || + authority.thresholds.incidents.minimumQualified !== 20 || + authority.thresholds.pilots.minimumQualified !== 3 || + authority.thresholds.pilots.targetMaximum !== 5 || + authority.thresholds.pilots.minimumIndependentProjects !== 3 || + authority.thresholds.pilots.minimumIndependentOrganizations !== 3 + ) { + fail("authority thresholds drifted"); + } + exactKeys( + authority.eligibility, + [ + "allowedRelationships", + "excludedOrganizationIds", + "excludedRepositoryOwners", + "requiredConsentStatus", + "requiredReviewStatus", + "rawEvidencePolicy", + ], + "authority.eligibility", + ); + strings(authority.eligibility.allowedRelationships, "authority.eligibility.allowedRelationships"); + strings(authority.eligibility.excludedOrganizationIds, "authority.eligibility.excludedOrganizationIds"); + strings(authority.eligibility.excludedRepositoryOwners, "authority.eligibility.excludedRepositoryOwners"); + if ( + JSON.stringify(authority.eligibility.allowedRelationships) !== + JSON.stringify(["external-community", "external-customer", "external-partner"]) || + !authority.eligibility.excludedOrganizationIds.includes("celiums-solutions") || + !authority.eligibility.excludedOrganizationIds.includes("pliegocss") || + !authority.eligibility.excludedRepositoryOwners.includes("celiumsai") || + authority.eligibility.requiredConsentStatus !== "recorded" || + authority.eligibility.requiredReviewStatus !== "approved" || + authority.eligibility.rawEvidencePolicy !== "private-custody-hash-public-redacted-record" + ) { + fail("authority eligibility boundary drifted"); + } + const coverage = strings(authority.requiredCoverage, "authority.requiredCoverage"); + for (const required of [ + "ten-to-fifteen-interviews", + "twenty-incidents", + "three-to-five-pilots", + "three-independent-projects", + "three-independent-organizations", + "frozen-adapter-support-policy", + ]) { + if (!coverage.includes(required)) fail(`authority.requiredCoverage is missing ${required}`); + } + nonEmpty(authority.claimBoundary, "authority.claimBoundary"); +} + +export function loadExternalAdoptionBundle(root) { + const authority = JSON.parse( + readFileSync(resolve(root, "benchmarks", "external-adoption-v1", "authority.json"), "utf8"), + ); + const readReferenced = (entry) => + JSON.parse(readFileSync(repositoryPath(root, entry.path, entry.path), "utf8")); + return { + authority, + interviews: readReferenced(authority.evidence.interviews), + incidents: readReferenced(authority.evidence.incidents), + pilots: readReferenced(authority.evidence.pilots), + policy: readReferenced(authority.adapterSupportPolicy), + coexistence: JSON.parse( + readFileSync(resolve(root, "benchmarks", "adapter-coexistence-v1", "authority.json"), "utf8"), + ), + }; +} + +export function validateExternalAdoptionBundle(bundle, { root, verifyFiles = true } = {}) { + object(bundle, "bundle"); + if (typeof root !== "string" || root === "") fail("root is required"); + validateAuthority(bundle.authority, { root, verifyFiles }); + validatePolicy(bundle.policy, bundle.coexistence, { root, verifyFiles }); + const interviews = validateInterviews(bundle.interviews, bundle.authority); + const incidents = validateIncidents(bundle.incidents, bundle.authority); + const pilots = validatePilots(bundle.pilots, bundle.authority); + + const interviewIds = interviews.ids; + const incidentIds = incidents.ids; + const pilotIds = pilots.ids; + for (const [index, entry] of bundle.interviews.records.entries()) { + for (const id of entry.relatedIncidentIds) { + if (!incidentIds.has(id)) fail(`interviews.records[${index}] references unknown incident ${id}`); + } + for (const id of entry.relatedPilotIds) { + if (!pilotIds.has(id)) fail(`interviews.records[${index}] references unknown pilot ${id}`); + } + } + if (interviewIds.size !== interviews.count) fail("interview identity drifted"); + + const thresholds = bundle.authority.thresholds; + const coveredCoverage = []; + if (interviews.count >= thresholds.interviews.minimumQualified) { + coveredCoverage.push("ten-to-fifteen-interviews"); + } + if (incidents.count >= thresholds.incidents.minimumQualified) { + coveredCoverage.push("twenty-incidents"); + } + if (pilots.count >= thresholds.pilots.minimumQualified) { + coveredCoverage.push("three-to-five-pilots"); + } + if (pilots.projects.size >= thresholds.pilots.minimumIndependentProjects) { + coveredCoverage.push("three-independent-projects"); + } + if (pilots.organizations.size >= thresholds.pilots.minimumIndependentOrganizations) { + coveredCoverage.push("three-independent-organizations"); + } + coveredCoverage.push("frozen-adapter-support-policy"); + const missingCoverage = bundle.authority.requiredCoverage.filter( + (item) => !coveredCoverage.includes(item), + ); + + return { + schemaVersion: 1, + kind: "pliegocss-external-adoption-status", + studyId: bundle.authority.studyId, + result: missingCoverage.length === 0 ? "ready" : "blocked", + counts: { + qualifiedInterviews: interviews.count, + qualifiedIncidents: incidents.count, + qualifiedPilots: pilots.count, + independentProjects: pilots.projects.size, + independentOrganizations: pilots.organizations.size, + }, + thresholds, + coveredCoverage, + missingCoverage, + policy: { + status: bundle.policy.status, + releaseLine: bundle.policy.releaseLine, + }, + recruitment: structuredClone(bundle.authority.recruitment), + claimBoundary: bundle.authority.claimBoundary, + }; +} diff --git a/scripts/external-adoption-v1.test.mjs b/scripts/external-adoption-v1.test.mjs new file mode 100644 index 0000000..1b8bdff --- /dev/null +++ b/scripts/external-adoption-v1.test.mjs @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ExternalAdoptionError, + loadExternalAdoptionBundle, + validateExternalAdoptionBundle, +} from "./external-adoption-v1.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const current = loadExternalAdoptionBundle(root); +const status = validateExternalAdoptionBundle(current, { root }); +assert.equal(status.result, "blocked"); +assert.deepEqual(status.counts, { + qualifiedInterviews: 0, + qualifiedIncidents: 0, + qualifiedPilots: 0, + independentProjects: 0, + independentOrganizations: 0, +}); +assert.deepEqual(status.coveredCoverage, ["frozen-adapter-support-policy"]); +assert(status.missingCoverage.includes("ten-to-fifteen-interviews")); +assert(status.missingCoverage.includes("twenty-incidents")); +assert(status.missingCoverage.includes("three-to-five-pilots")); + +function clone() { + return structuredClone(current); +} + +function hex(value, width) { + return value.toString(16).padStart(width, "0"); +} + +const ready = clone(); +for (let index = 1; index <= 3; index += 1) { + ready.pilots.records.push({ + id: `pilot-${index}`, + projectId: `project-${index}`, + organizationId: `organization-${index}`, + repositoryOwner: `external-owner-${index}`, + relationship: "external-community", + adapter: ["html", "vite", "pliegors"][index - 1], + tailwindProfile: index === 1 ? "tailwind-v3-lts" : "tailwind-v4-current", + startedAt: "2026-07-01T00:00:00Z", + completedAt: "2026-07-02T00:00:00Z", + status: "completed", + maintainerIds: [`maintainer-${index}`], + consent: { + status: "recorded", + recordedAt: "2026-06-30T00:00:00Z", + researchUse: true, + publicRedactedRecord: true, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + source: { + url: `https://github.com/external-owner-${index}/project-${index}`, + commit: hex(index, 40), + sha256: `sha256:${hex(100 + index, 64)}`, + }, + outcomes: { + adoptionCompleted: true, + rollbackVerified: true, + browserEquivalenceVerified: true, + blockingFailures: 0, + }, + review: { + reviewer: `reviewer-${index}`, + reviewedAt: "2026-07-03T00:00:00Z", + status: "approved", + }, + }); +} +for (let index = 1; index <= 20; index += 1) { + ready.incidents.records.push({ + id: `incident-${index}`, + projectId: `project-${((index - 1) % 3) + 1}`, + organizationId: `organization-${((index - 1) % 3) + 1}`, + relationship: "external-community", + occurredAt: "2026-06-20T00:00:00Z", + framework: index % 2 === 0 ? "Vite" : "Static HTML", + category: "cascade-conflict", + severity: "medium", + synthetic: false, + provenance: "external-project-observation", + consent: { + status: "recorded", + recordedAt: "2026-06-21T00:00:00Z", + researchUse: true, + publicRedactedRecord: true, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + expectedDiagnosis: "Detect the observed cascade conflict.", + allowedAmbiguity: "Equivalent selector wording is allowed.", + source: { + custodyRef: `private:g7/incidents/incident-${index}`, + sha256: `sha256:${hex(200 + index, 64)}`, + }, + review: { + reviewer: `reviewer-${((index - 1) % 3) + 1}`, + reviewedAt: "2026-06-22T00:00:00Z", + status: "approved", + }, + }); +} +for (let index = 1; index <= 10; index += 1) { + ready.interviews.records.push({ + id: `interview-${index}`, + participantId: `participant-${index}`, + organizationId: `organization-${index}`, + relationship: "external-community", + conductedAt: "2026-07-04T00:00:00Z", + consent: { + status: "recorded", + recordedAt: "2026-07-03T00:00:00Z", + researchUse: true, + publicRedactedRecord: true, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + source: { + custodyRef: `private:g7/interviews/interview-${index}`, + sha256: `sha256:${hex(300 + index, 64)}`, + }, + review: { + reviewer: `reviewer-${((index - 1) % 3) + 1}`, + reviewedAt: "2026-07-05T00:00:00Z", + status: "approved", + }, + findings: ["A reviewed external finding."], + relatedIncidentIds: index <= 3 ? [`incident-${index}`] : [], + relatedPilotIds: index <= 3 ? [`pilot-${index}`] : [], + }); +} +const readyStatus = validateExternalAdoptionBundle(ready, { root, verifyFiles: false }); +assert.equal(readyStatus.result, "ready"); +assert.deepEqual(readyStatus.missingCoverage, []); + +const unredactedPilot = structuredClone(ready); +delete unredactedPilot.pilots.records[0].redaction; +assert.throws( + () => validateExternalAdoptionBundle(unredactedPilot, { root, verifyFiles: false }), + (error) => + error instanceof ExternalAdoptionError && + /pilots\.records\[0\] fields drifted/u.test(error.message), +); + +const forgedRecruitment = clone(); +forgedRecruitment.authority.recruitment.publicCohortUrl = + "https://example.invalid/unreviewed"; +assert.throws( + () => validateExternalAdoptionBundle(forgedRecruitment, { root, verifyFiles: false }), + (error) => + error instanceof ExternalAdoptionError && + /recruitment contract drifted/u.test(error.message), +); + +const internalPilot = clone(); +internalPilot.pilots.records.push({ + id: "pilot-internal", + projectId: "project-internal", + organizationId: "celiums-solutions", + repositoryOwner: "celiumsai", + relationship: "external-community", + adapter: "html", + tailwindProfile: "tailwind-v4-current", + startedAt: "2026-07-01T00:00:00Z", + completedAt: "2026-07-02T00:00:00Z", + status: "completed", + maintainerIds: ["maintainer-one"], + consent: { + status: "recorded", + recordedAt: "2026-07-02T00:00:00Z", + researchUse: true, + publicRedactedRecord: true, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + source: { + url: "https://github.com/celiumsai/pliegocss", + commit: "0000000000000000000000000000000000000000", + sha256: `sha256:${"0".repeat(64)}`, + }, + outcomes: { + adoptionCompleted: true, + rollbackVerified: true, + browserEquivalenceVerified: true, + blockingFailures: 0, + }, + review: { + reviewer: "reviewer-one", + reviewedAt: "2026-07-02T00:00:00Z", + status: "approved", + }, +}); +assert.throws( + () => validateExternalAdoptionBundle(internalPilot, { root, verifyFiles: false }), + (error) => + error instanceof ExternalAdoptionError && + /excluded repository owner|excluded organization/u.test(error.message), +); + +const syntheticIncident = clone(); +syntheticIncident.incidents.records.push({ + id: "incident-synthetic", + projectId: "project-external", + organizationId: "organization-external", + relationship: "external-community", + occurredAt: "2026-07-01T00:00:00Z", + framework: "Vite", + category: "cascade-conflict", + severity: "medium", + synthetic: true, + provenance: "external-project-observation", + consent: { + status: "recorded", + recordedAt: "2026-07-02T00:00:00Z", + researchUse: true, + publicRedactedRecord: true, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + expectedDiagnosis: "Detect the cascade conflict.", + allowedAmbiguity: "Equivalent selector wording is allowed.", + source: { + custodyRef: "private:g7/incidents/incident-synthetic", + sha256: `sha256:${"1".repeat(64)}`, + }, + review: { + reviewer: "reviewer-one", + reviewedAt: "2026-07-02T00:00:00Z", + status: "approved", + }, +}); +assert.throws( + () => validateExternalAdoptionBundle(syntheticIncident, { root, verifyFiles: false }), + (error) => error instanceof ExternalAdoptionError && /not a real external incident/u.test(error.message), +); + +const unconsentedInterview = clone(); +unconsentedInterview.interviews.records.push({ + id: "interview-one", + participantId: "participant-one", + organizationId: "organization-external", + relationship: "external-community", + conductedAt: "2026-07-01T00:00:00Z", + consent: { + status: "recorded", + recordedAt: "2026-07-01T00:00:00Z", + researchUse: true, + publicRedactedRecord: false, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + source: { + custodyRef: "private:g7/interviews/interview-one", + sha256: `sha256:${"2".repeat(64)}`, + }, + review: { + reviewer: "reviewer-one", + reviewedAt: "2026-07-02T00:00:00Z", + status: "approved", + }, + findings: ["A real finding."], + relatedIncidentIds: [], + relatedPilotIds: [], +}); +assert.throws( + () => validateExternalAdoptionBundle(unconsentedInterview, { root, verifyFiles: false }), + (error) => error instanceof ExternalAdoptionError && /must permit research use/u.test(error.message), +); + +process.stdout.write("external adoption authority contract: pass\n"); diff --git a/scripts/measure-benchmark-authority-v2.mjs b/scripts/measure-benchmark-authority-v2.mjs new file mode 100644 index 0000000..71cfb9f --- /dev/null +++ b/scripts/measure-benchmark-authority-v2.mjs @@ -0,0 +1,569 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { + availableParallelism, + cpus, + release as osRelease, + totalmem, + type as osType, + version as osVersion, +} from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib"; +import { + authorityPath, + canonicalLaneMap, + corpusMetrics, + extractClassValues, + loadAuthority, + materializeCorpus, + readInstalledPackage, + repositoryRoot, + resolveLaneCli, + round, + sha256, + summarize, +} from "./benchmark-authority-v2.mjs"; +import { targetDirectoryForRustcIdentity } from "./rust-target.mjs"; + +const require = createRequire(import.meta.url); +const escapeClassName = require( + join(repositoryRoot, "node_modules", "tailwindcss-v3-lts", "lib", "util", "escapeClassName.js"), +).default; +const benchmarkRoot = join(repositoryRoot, "target", "benchmarks", "benchmark-authority-v2"); +const localResultPath = join( + repositoryRoot, + "benchmarks", + "results", + "benchmark-authority-v2.local.json", +); +const evidenceRoot = join(repositoryRoot, "benchmarks", "evidence", "v2"); +const harnessPath = fileURLToPath(import.meta.url); +const runnerSourcePath = join(repositoryRoot, "scripts", "benchmark-process-metrics.rs"); +const packagePath = join(repositoryRoot, "package.json"); +const lockPath = join(repositoryRoot, "pnpm-lock.yaml"); + +function fail(message) { + throw new Error(`Benchmark Authority v2 measurement: ${message}`); +} + +function parsePositiveInteger(value, label, allowZero = false) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < (allowZero ? 0 : 1)) { + fail(`${label} must be ${allowZero ? "a non-negative" : "a positive"} integer`); + } + return parsed; +} + +function parseOptions(arguments_, authority) { + const options = { + pairs: authority.methodology.measuredPairs, + warmups: authority.methodology.warmupPairs, + laneIds: authority.lanes.map((lane) => lane.id), + corpusIds: authority.corpora.map((corpus) => corpus.id), + evidence: null, + smoke: false, + }; + for (const argument of arguments_) { + if (argument === "--smoke") { + options.smoke = true; + options.pairs = 1; + options.warmups = 0; + } else if (argument.startsWith("--pairs=")) { + options.pairs = parsePositiveInteger(argument.slice("--pairs=".length), "--pairs"); + } else if (argument.startsWith("--warmups=")) { + options.warmups = parsePositiveInteger( + argument.slice("--warmups=".length), + "--warmups", + true, + ); + } else if (argument.startsWith("--lanes=")) { + options.laneIds = argument.slice("--lanes=".length).split(",").filter(Boolean); + } else if (argument.startsWith("--corpora=")) { + options.corpusIds = argument.slice("--corpora=".length).split(",").filter(Boolean); + } else if (argument.startsWith("--evidence=")) { + options.evidence = resolve(repositoryRoot, argument.slice("--evidence=".length)); + } else { + fail(`unknown argument ${JSON.stringify(argument)}`); + } + } + if (options.evidence && (options.smoke || options.pairs !== authority.methodology.measuredPairs || options.warmups !== authority.methodology.warmupPairs)) { + fail("evidence requires the canonical 5 warmup and 30 measured pairs"); + } + return options; +} + +function run(command, arguments_, options = {}) { + const result = spawnSync(command, arguments_, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + timeout: 180_000, + maxBuffer: 16 * 1024 * 1024, + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail( + `${command} ${arguments_.join(" ")} exited ${result.status}\n${`${result.stderr ?? ""}`.slice(-4_096)}`, + ); + } + return result; +} + +function git(arguments_, fallback = "") { + try { + return run("git", arguments_).stdout.trim() || fallback; + } catch { + return fallback; + } +} + +function gitState() { + const status = run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).stdout; + return { + commit: git(["rev-parse", "HEAD"], "unborn"), + dirty: status.length > 0, + statusEntryCount: status.split("\0").filter(Boolean).length, + statusSha256: sha256(Buffer.from(status, "utf8")), + }; +} + +function safeEvidencePath(path) { + if (!path) return; + const child = relative(evidenceRoot, path); + if ( + !child || + child === ".." || + child.startsWith(`..${sep}`) || + isAbsolute(child) || + !path.endsWith(".json") || + existsSync(path) + ) { + fail("--evidence must be a new .json file below benchmarks/evidence/v2"); + } +} + +function capturePowerPlan() { + const declared = process.env.PLIEGO_BENCH_POWER_PLAN?.trim(); + if (declared) return { status: "declared", source: "PLIEGO_BENCH_POWER_PLAN", value: declared }; + const probes = + process.platform === "win32" + ? [["powercfg", ["/getactivescheme"]]] + : process.platform === "darwin" + ? [["pmset", ["-g"]]] + : [["powerprofilesctl", ["get"]]]; + for (const [command, arguments_] of probes) { + const result = spawnSync(command, arguments_, { encoding: "utf8", windowsHide: true }); + if (!result.error && result.status === 0 && result.stdout.trim()) { + return { status: "recorded", source: `${command} ${arguments_.join(" ")}`, value: result.stdout.trim() }; + } + } + return { status: "unavailable", source: null, value: null }; +} + +function captureSecurityTooling(evidence) { + const status = process.env.PLIEGO_BENCH_SECURITY_TOOLING_STATUS?.trim().toLowerCase(); + const tooling = process.env.PLIEGO_BENCH_SECURITY_TOOLING?.trim() || null; + const allowed = new Set(["active", "disabled", "not-installed", "unknown"]); + if (!status) { + if (evidence) fail("evidence requires PLIEGO_BENCH_SECURITY_TOOLING_STATUS"); + return { status: "not-reported", tooling: null }; + } + if (!allowed.has(status)) fail("invalid PLIEGO_BENCH_SECURITY_TOOLING_STATUS"); + if ((status === "active" || status === "disabled") && !tooling) { + fail(`${status} security tooling requires PLIEGO_BENCH_SECURITY_TOOLING`); + } + return { status, tooling }; +} + +function toolchain() { + const rustcVerbose = run("rustc", ["+1.96.0", "-vV"]).stdout.trim(); + const host = /^host: (\S+)$/mu.exec(rustcVerbose)?.[1]; + if (!host) fail("cannot resolve the Rust 1.96.0 host target"); + return { + cargo: run("cargo", ["+1.96.0", "--version"]).stdout.trim(), + rustc: /^rustc .+$/mu.exec(rustcVerbose)?.[0] ?? "unknown", + rustcVerbose, + host, + }; +} + +function buildTools(toolchainValue) { + const cargoTarget = targetDirectoryForRustcIdentity( + repositoryRoot, + process.env, + toolchainValue.rustcVerbose, + ); + const runnerRoot = join(cargoTarget, "benchmark-tools"); + mkdirSync(cargoTarget, { recursive: true }); + mkdirSync(runnerRoot, { recursive: true }); + const cargoEnvironment = { + ...process.env, + CARGO_INCREMENTAL: "0", + CARGO_TARGET_DIR: cargoTarget, + CARGO_TERM_COLOR: "never", + }; + run( + "cargo", + ["+1.96.0", "build", "--release", "--locked", "--target", toolchainValue.host, "-p", "pliego-cssc"], + { env: cargoEnvironment }, + ); + const pliego = join( + cargoTarget, + toolchainValue.host, + "release", + process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", + ); + const runner = join( + runnerRoot, + process.platform === "win32" ? "benchmark-process-metrics.exe" : "benchmark-process-metrics", + ); + run("rustc", ["+1.96.0", runnerSourcePath, "-O", "-o", runner]); + return { pliego, runner, cargoTarget }; +} + +function measuredProcess(runner, command, arguments_, cwd) { + const result = run(runner, [command, ...arguments_], { cwd, timeout: 60_000 }); + const metrics = JSON.parse(result.stdout.trim()); + if ( + metrics.schemaVersion !== 1 || + !Number.isSafeInteger(metrics.elapsedNs) || + metrics.elapsedNs <= 0 || + !Number.isSafeInteger(metrics.peakWorkingSetBytes) || + metrics.peakWorkingSetBytes <= 0 || + metrics.exitCode !== 0 + ) { + fail(`invalid process metrics: ${result.stdout}`); + } + return metrics; +} + +function v4Input(packageAlias) { + return `@layer theme, utilities;\n@import "${packageAlias}/theme.css" layer(theme);\n@import "${packageAlias}/utilities.css" layer(utilities) source(none);\n@source "./fixture.html";\n@theme {\n --color-canvas: oklch(0.985 0.004 80);\n --color-surface: oklch(1 0 0);\n --color-surface-raised: oklch(0.96 0.006 80);\n --color-ink: oklch(0.19 0.012 70);\n --color-muted: oklch(0.5 0.014 70);\n --color-accent: oklch(0.58 0.19 32);\n --color-accent-strong: oklch(0.5 0.18 30);\n --color-line: oklch(0.88 0.008 75);\n}\n`; +} + +function v3Config() { + return `module.exports = {\n content: ["./fixture.html"],\n corePlugins: { preflight: false },\n theme: {\n extend: {\n colors: {\n canvas: "oklch(0.985 0.004 80 / )",\n surface: "oklch(1 0 0 / )",\n "surface-raised": "oklch(0.96 0.006 80 / )",\n ink: "oklch(0.19 0.012 70 / )",\n muted: "oklch(0.5 0.014 70 / )",\n accent: "oklch(0.58 0.19 32 / )",\n "accent-strong": "oklch(0.5 0.18 30 / )",\n line: "oklch(0.88 0.008 75 / )"\n }\n }\n }\n};\n`; +} + +function prepareLaneWorkspace(workspace, lane, corpus, html, pliego) { + const laneRoot = join(workspace, lane.id, corpus.id); + mkdirSync(laneRoot, { recursive: true }); + const fixture = join(laneRoot, "fixture.html"); + const styles = join(laneRoot, "styles.txt"); + const tailwindInput = join(laneRoot, "input.css"); + const tailwindOutput = join(laneRoot, "tailwind.css"); + const pliegoOutput = join(laneRoot, "pliego.css"); + const manifestPath = join(laneRoot, "manifest.json"); + const manifestCss = join(laneRoot, "manifest.css"); + const classValues = extractClassValues(html); + writeFileSync(fixture, html); + writeFileSync(styles, `${classValues.join("\n")}\n`); + if (lane.version.startsWith("3.")) { + writeFileSync(tailwindInput, "@tailwind utilities;\n"); + writeFileSync(join(laneRoot, "tailwind.config.cjs"), v3Config()); + } else { + writeFileSync(tailwindInput, v4Input(lane.packageAlias)); + } + const cli = resolveLaneCli(lane); + const tailwindArguments = [cli, "-i", tailwindInput, "-o", tailwindOutput, "--minify"]; + if (lane.version.startsWith("3.")) { + tailwindArguments.push("-c", join(laneRoot, "tailwind.config.cjs")); + } + const pliegoArguments = [ + "compile", + "--input", + styles, + "--seed", + "--theme", + "--output", + pliegoOutput, + ]; + run(process.execPath, tailwindArguments, { cwd: laneRoot, timeout: 60_000 }); + run(pliego, pliegoArguments, { cwd: laneRoot, timeout: 60_000 }); + run( + pliego, + [ + ...pliegoArguments.slice(0, -1), + manifestCss, + "--manifest", + manifestPath, + ], + { cwd: laneRoot, timeout: 60_000 }, + ); + return { + laneRoot, + fixture, + styles, + tailwindOutput, + pliegoOutput, + manifestPath, + manifestCss, + classValues, + commands: { + pliego: { command: pliego, arguments: pliegoArguments }, + tailwind: { command: process.execPath, arguments: tailwindArguments }, + }, + }; +} + +function outputSize(bytes) { + return { + rawBytes: bytes.byteLength, + gzipBytes: gzipSync(bytes, { level: 9 }).byteLength, + brotliBytes: brotliCompressSync(bytes, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength, + sha256: sha256(bytes), + }; +} + +function generatedHtml(html, manifest) { + const bySource = new Map(); + for (const style of manifest.styles) { + for (const origin of style.origins) { + const existing = bySource.get(origin.source); + if (existing && existing !== style.className) fail(`manifest maps ${origin.source} twice`); + bySource.set(origin.source, style.className); + } + } + let replacements = 0; + const rendered = html.replace(/class="([^"]*)"/gu, (_, source) => { + const replacement = bySource.get(source); + if (!replacement) fail(`manifest has no class for ${JSON.stringify(source)}`); + replacements += 1; + return `class="${replacement}"`; + }); + return { bytes: Buffer.from(rendered, "utf8"), replacements }; +} + +function candidateCoverage(css, html) { + const tokens = [...new Set(extractClassValues(html).flatMap((value) => value.trim().split(/\s+/u).filter(Boolean)))]; + const missing = tokens.filter((token) => !css.includes(`.${escapeClassName(token)}`)); + return { + expected: tokens.length, + emitted: tokens.length - missing.length, + ratio: round((tokens.length - missing.length) / tokens.length), + missing, + }; +} + +function measureComparison(prepared, tools, html, warmups, pairs) { + const invoke = (kind, timed) => { + const command = prepared.commands[kind]; + if (timed) { + return measuredProcess(tools.runner, command.command, command.arguments, prepared.laneRoot); + } + run(command.command, command.arguments, { cwd: prepared.laneRoot, timeout: 60_000 }); + return null; + }; + for (let index = 0; index < warmups; index += 1) { + const order = index % 2 === 0 ? ["pliego", "tailwind"] : ["tailwind", "pliego"]; + for (const kind of order) invoke(kind, false); + } + + const observations = []; + const hashes = { pliego: [], tailwind: [] }; + for (let index = 0; index < pairs; index += 1) { + const order = index % 2 === 0 ? ["pliego", "tailwind"] : ["tailwind", "pliego"]; + const record = { pair: index + 1, order }; + for (const kind of order) { + record[kind] = invoke(kind, true); + hashes[kind].push(sha256(readFileSync(prepared[`${kind}Output`]))); + } + record.deltaNs = record.pliego.elapsedNs - record.tailwind.elapsedNs; + record.tailwindToPliegoRatio = round(record.tailwind.elapsedNs / record.pliego.elapsedNs); + record.peakWorkingSetDeltaBytes = + record.pliego.peakWorkingSetBytes - record.tailwind.peakWorkingSetBytes; + observations.push(record); + } + for (const kind of ["pliego", "tailwind"]) { + const unique = new Set(hashes[kind]); + if (unique.size !== 1) fail(`${kind} emitted ${unique.size} hashes across measured pairs`); + rmSync(prepared[`${kind}Output`], { force: true }); + invoke(kind, false); + const freshHash = sha256(readFileSync(prepared[`${kind}Output`])); + if (!unique.has(freshHash)) fail(`${kind} fresh output differs from measured output`); + } + + const pliegoCss = readFileSync(prepared.pliegoOutput); + const tailwindCss = readFileSync(prepared.tailwindOutput); + const manifestCss = readFileSync(prepared.manifestCss); + if (!pliegoCss.equals(manifestCss)) fail("manifest generation changed PliegoCSS output"); + const manifest = JSON.parse(readFileSync(prepared.manifestPath, "utf8")); + const rewritten = generatedHtml(html, manifest); + const pliegoCssSize = outputSize(pliegoCss); + const tailwindCssSize = outputSize(tailwindCss); + const utilityHtmlSize = outputSize(Buffer.from(html, "utf8")); + const generatedHtmlSize = outputSize(rewritten.bytes); + const coverage = candidateCoverage(tailwindCss.toString("utf8"), html); + if (coverage.ratio !== 1) { + fail(`Tailwind candidate coverage is ${coverage.emitted}/${coverage.expected}: ${coverage.missing.join(", ")}`); + } + + const pliegoLatency = observations.map((pair) => pair.pliego.elapsedNs / 1_000_000); + const tailwindLatency = observations.map((pair) => pair.tailwind.elapsedNs / 1_000_000); + return { + pairs: observations, + summaries: { + pliegoLatency: summarize(pliegoLatency, "ms"), + tailwindLatency: summarize(tailwindLatency, "ms"), + pairedLatencyDelta: summarize( + observations.map((pair) => pair.deltaNs / 1_000_000), + "ms", + ), + tailwindToPliegoRatio: summarize( + observations.map((pair) => pair.tailwindToPliegoRatio), + "ratio", + ), + pliegoPeakWorkingSet: summarize( + observations.map((pair) => pair.pliego.peakWorkingSetBytes), + "bytes", + ), + tailwindPeakWorkingSet: summarize( + observations.map((pair) => pair.tailwind.peakWorkingSetBytes), + "bytes", + ), + pairedPeakWorkingSetDelta: summarize( + observations.map((pair) => pair.peakWorkingSetDeltaBytes), + "bytes", + ), + }, + determinism: { + measuredPairs: pairs, + freshOutputVerified: true, + pliegoSha256: pliegoCssSize.sha256, + tailwindSha256: tailwindCssSize.sha256, + }, + coverage, + output: { + pliego: { + css: pliegoCssSize, + html: generatedHtmlSize, + transferGzipBytes: pliegoCssSize.gzipBytes + generatedHtmlSize.gzipBytes, + transferBrotliBytes: pliegoCssSize.brotliBytes + generatedHtmlSize.brotliBytes, + classAttributesRewritten: rewritten.replacements, + }, + tailwind: { + css: tailwindCssSize, + html: utilityHtmlSize, + transferGzipBytes: tailwindCssSize.gzipBytes + utilityHtmlSize.gzipBytes, + transferBrotliBytes: tailwindCssSize.brotliBytes + utilityHtmlSize.brotliBytes, + }, + }, + }; +} + +const authority = loadAuthority(); +const options = parseOptions(process.argv.slice(2), authority); +const laneMap = canonicalLaneMap(authority); +const lanes = options.laneIds.map((id) => laneMap.get(id) ?? fail(`unknown lane ${id}`)); +const corpusMap = new Map(authority.corpora.map((corpus) => [corpus.id, corpus])); +const corpora = options.corpusIds.map((id) => corpusMap.get(id) ?? fail(`unknown corpus ${id}`)); +safeEvidencePath(options.evidence); +const stateBefore = gitState(); +if (options.evidence && stateBefore.dirty) fail("evidence requires a clean Git worktree"); +const toolchainValue = toolchain(); +const tools = buildTools(toolchainValue); +const runDirectory = join(benchmarkRoot, `run-${process.pid}-${Date.now()}`); +mkdirSync(runDirectory, { recursive: true }); + +try { + const results = {}; + for (const lane of lanes) { + results[lane.id] = { + role: lane.role, + version: lane.version, + cliVersion: lane.cliVersion, + packageManifestSha256: sha256(readFileSync(readInstalledPackage(lane.packageAlias).path)), + cliManifestSha256: sha256(readFileSync(readInstalledPackage(lane.cliPackageAlias).path)), + corpora: {}, + }; + for (const corpus of corpora) { + process.stdout.write(`[measuring] ${lane.id}/${corpus.id}\n`); + const html = materializeCorpus(corpus); + const metrics = corpusMetrics(html); + if (JSON.stringify(metrics) !== JSON.stringify(corpus.expected)) { + fail(`${corpus.id} materialization drifted from oracle`); + } + const prepared = prepareLaneWorkspace(runDirectory, lane, corpus, html, tools.pliego); + results[lane.id].corpora[corpus.id] = { + fixture: metrics, + ...measureComparison(prepared, tools, html, options.warmups, options.pairs), + }; + } + } + const stateAfter = gitState(); + if (JSON.stringify(stateBefore) !== JSON.stringify(stateAfter)) fail("source tree changed during measurement"); + const result = { + schemaVersion: 2, + benchmark: "PliegoCSS Benchmark Authority v2 paired competitor oracle", + generatedAtUtc: new Date().toISOString(), + mode: options.evidence ? "immutable-evidence" : options.smoke ? "smoke" : "machine-local", + authority: { + path: relative(repositoryRoot, authorityPath).replaceAll("\\", "/"), + sha256: sha256(readFileSync(authorityPath)), + observedAtUtc: authority.observedAtUtc, + expiresAtUtc: authority.expiresAtUtc, + primaryLane: authority.primaryLane, + resetContract: authority.resetContract, + }, + provenance: { + git: stateBefore, + harness: { path: relative(repositoryRoot, harnessPath).replaceAll("\\", "/"), sha256: sha256(readFileSync(harnessPath)) }, + processMetricsRunner: { path: relative(repositoryRoot, runnerSourcePath).replaceAll("\\", "/"), sha256: sha256(readFileSync(runnerSourcePath)) }, + packageJsonSha256: sha256(readFileSync(packagePath)), + pnpmLockSha256: sha256(readFileSync(lockPath)), + executableSha256: sha256(readFileSync(tools.pliego)), + }, + methodology: { + design: authority.methodology.design, + warmupPairs: options.warmups, + measuredPairs: options.pairs, + pairOrder: authority.methodology.pairOrder, + freshProcess: true, + completeSamples: true, + freshOutputVerification: true, + memoryMetric: authority.methodology.memoryMetric, + compression: authority.methodology.compression, + }, + environment: { + os: { platform: process.platform, type: osType(), release: osRelease(), version: osVersion(), architecture: process.arch }, + cpu: { models: [...new Set(cpus().map((cpu) => cpu.model))], logicalCoreCount: cpus().length, availableParallelism: availableParallelism() }, + totalMemoryBytes: totalmem(), + node: process.version, + zlib: process.versions.zlib, + toolchain: toolchainValue, + cargoTarget: relative(repositoryRoot, tools.cargoTarget).replaceAll("\\", "/"), + powerPlan: capturePowerPlan(), + securityTooling: captureSecurityTooling(options.evidence), + }, + lanes: results, + claimBoundary: authority.methodology.scorePolicy, + }; + const serialized = `${JSON.stringify(result, null, 2)}\n`; + mkdirSync(dirname(localResultPath), { recursive: true }); + writeFileSync(localResultPath, serialized); + if (options.evidence) { + mkdirSync(dirname(options.evidence), { recursive: true }); + writeFileSync(options.evidence, serialized, { flag: "wx" }); + } + process.stdout.write( + `${JSON.stringify({ schemaVersion: 2, status: "passed", mode: result.mode, lanes: Object.keys(results), corpora: options.corpusIds, pairs: options.pairs, result: relative(repositoryRoot, options.evidence ?? localResultPath).replaceAll("\\", "/") }, null, 2)}\n`, + ); +} finally { + const child = relative(benchmarkRoot, runDirectory); + if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child) || !basename(runDirectory).startsWith("run-")) { + fail(`refusing to remove unsafe benchmark run directory ${runDirectory}`); + } + rmSync(runDirectory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +} diff --git a/scripts/measure-media-merge.mjs b/scripts/measure-media-merge.mjs index e3b61e3..169ff8e 100644 --- a/scripts/measure-media-merge.mjs +++ b/scripts/measure-media-merge.mjs @@ -25,28 +25,28 @@ const expected = { profiles: { utilitiesOnly: { control: { - sha256: "acfe0a07ca05336b89504bc4c919d09afaf26f3a07c2cabb53fd7be3af2f7f7c", - rawBytes: 1_447, - gzipBytes: 648, + sha256: "efee258d711879f4e7c89e134c88c9c797d65e524727878a72e474894f17d2c2", + rawBytes: 1_449, + gzipBytes: 649, mediaWrappers: 20, }, candidate: { - sha256: "3eb3494eb3b128ab1ce84b1546cc08d17c179b2710175016e25e331d10070287", - rawBytes: 1_010, - gzipBytes: 638, + sha256: "ac316f5581edfef5562ade30dc758815ab724665cade8b42ea748349d293c5f1", + rawBytes: 1_012, + gzipBytes: 637, mediaWrappers: 1, }, }, themeAndUtilities: { control: { - sha256: "4e241be64c52679bfbe5b2966fccf8b00307fdd0bd06c3af8675c95b8a0d0eb3", - rawBytes: 1_818, - gzipBytes: 822, + sha256: "908dbf7df76c4598f89bf9bcf9c6b3b91918ca964275a6e1536d11419d31d608", + rawBytes: 1_820, + gzipBytes: 821, mediaWrappers: 20, }, candidate: { - sha256: "5697d5c24f2a2441519754cb6e3bae0ae8e46e34636964a0c3118be97cbd625b", - rawBytes: 1_381, + sha256: "05b254d481de13dd11d235308e5d8bf3dc977829dec3e71f1fc042b2f7793f45", + rawBytes: 1_383, gzipBytes: 809, mediaWrappers: 1, }, @@ -54,15 +54,15 @@ const expected = { }, neutralFixtures: { gateA: { - sha256: "12b6d497962ec8ae30d1c9343ca180f65a81f628161d038a653ac7be32fd6688", + sha256: "1d879683af938faba7a7e44a0ad330e9f2f6a5f6b363b0f1556cc56711cd6f5b", rawBytes: 6_548, - gzipBytes: 1_594, + gzipBytes: 1_596, mediaWrappers: 0, }, gateB: { - sha256: "d873a432d1174af4715f7cb33359c6ad2781f611f9712e3a72b5630aa6f1bffc", - rawBytes: 10_468, - gzipBytes: 2_047, + sha256: "757e259d072723eb06ea5517627ac3422e73c16f5eb36b6ecc57c8aa104c126d", + rawBytes: 10_461, + gzipBytes: 2_046, mediaWrappers: 12, }, }, diff --git a/scripts/measure-pliego-gate-a.mjs b/scripts/measure-pliego-gate-a.mjs index 6ff344c..4a43723 100644 --- a/scripts/measure-pliego-gate-a.mjs +++ b/scripts/measure-pliego-gate-a.mjs @@ -663,7 +663,7 @@ try { manifest.schemaVersion !== 3 || manifest.styleIdFormatVersion !== 2 || manifest.classNameFormatVersion !== 1 || - manifest.themeIdFormatVersion !== 2 + manifest.themeIdFormatVersion !== 3 ) { throw new Error( `expected manifest schema/identity formats 3/2/1/2, received ${manifest.schemaVersion}/${manifest.styleIdFormatVersion}/${manifest.classNameFormatVersion}/${manifest.themeIdFormatVersion}`, diff --git a/scripts/measure-pliego-gate-b.mjs b/scripts/measure-pliego-gate-b.mjs index 7aea6c1..0522536 100644 --- a/scripts/measure-pliego-gate-b.mjs +++ b/scripts/measure-pliego-gate-b.mjs @@ -1,867 +1,3 @@ -import { createHash, randomUUID } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises"; -import { - availableParallelism, - cpus, - release as osRelease, - tmpdir, - totalmem, - type as osType, - version as osVersion, -} from "node:os"; -import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { gzipSync } from "node:zlib"; - -const harnessPath = fileURLToPath(import.meta.url); -const scriptDirectory = dirname(harnessPath); -const repositoryRoot = resolve(scriptDirectory, ".."); -const fixturePath = join(repositoryRoot, "benchmarks", "tailwind-v4", "fixtures.html"); -const resultsPath = join(repositoryRoot, "benchmarks", "results", "pliego-gate-b.local.json"); -const evidenceRoot = join(repositoryRoot, "benchmarks", "evidence"); -const cargoTargetDirectory = join(repositoryRoot, "target", "benchmarks", "pliego-gate-b"); -const cargoHomeDirectory = join(repositoryRoot, "target", "benchmarks", "cargo-home-gate-b"); -const runLockPath = join(repositoryRoot, "target", "benchmarks", "locks", "pliego-gate-b.lock"); -const evidencePath = parseEvidencePath(process.argv.slice(2)); -const measuredProcessCount = 30; -const warmupProcessCount = 5; -const expectedClassAttributeCount = 44; -const requiredVariants = [ - "disabled", - "focus", - "focus-visible", - "hover", - "lg", - "md", - "placeholder", - "sm", -]; -const emittedVariantMarkers = { - disabled: ":disabled{", - focus: ":focus{", - "focus-visible": ":focus-visible{", - hover: ":hover{", - lg: "@media (width>=64rem)", - md: "@media (width>=48rem)", - placeholder: "::placeholder{", - sm: "@media (width>=40rem)", -}; -const frozenTailwind = { - version: "4.3.2", - generated_at: "2026-07-12T22:30:41.861Z", - profiles: { - "full-complete": { - median_ms: 210.202, - min_ms: 187.341, - p95_ms: 318.531, - css_raw_bytes: 12_377, - css_gzip_bytes: 3_333, - html_gzip_bytes: 1_506, - transfer_gzip_bytes: 4_839, - }, - "no-preflight-complete": { - median_ms: 204.826, - min_ms: 185.374, - p95_ms: 418.003, - css_raw_bytes: 8_618, - css_gzip_bytes: 2_327, - html_gzip_bytes: 1_506, - transfer_gzip_bytes: 3_833, - }, - }, -}; -const cargoInfluencePatterns = [ - /^CARGO_BUILD_/iu, - /^CARGO_ENCODED_RUSTFLAGS$/iu, - /^CARGO_HOME$/iu, - /^CARGO_INCREMENTAL$/iu, - /^CARGO_PROFILE_/iu, - /^CARGO_TARGET_/iu, - /^CARGO_TERM_/iu, - /^RUSTC$/iu, - /^RUSTC_BOOTSTRAP$/iu, - /^RUSTC_WORKSPACE_WRAPPER$/iu, - /^RUSTC_WRAPPER$/iu, - /^RUSTDOCFLAGS$/iu, - /^RUSTFLAGS$/iu, - /^RUSTUP_TOOLCHAIN$/iu, -]; - -function sanitizedCargoEnvironmentKeys() { - return Object.keys(process.env) - .filter((key) => cargoInfluencePatterns.some((pattern) => pattern.test(key))) - .sort(); -} - -function controlledCargoEnvironment() { - const environment = { ...process.env }; - for (const key of sanitizedCargoEnvironmentKeys()) { - delete environment[key]; - } - return { - ...environment, - CARGO_HOME: cargoHomeDirectory, - CARGO_INCREMENTAL: "0", - CARGO_TARGET_DIR: cargoTargetDirectory, - CARGO_TERM_COLOR: "never", - }; -} - -function parseEvidencePath(arguments_) { - if (arguments_.length === 0) { - return null; - } - if (arguments_.length !== 2 || arguments_[0] !== "--evidence" || !arguments_[1]) { - throw new Error( - "usage: node scripts/measure-pliego-gate-b.mjs [--evidence benchmarks/evidence/.json]", - ); - } - if (arguments_[1].includes("\0")) { - throw new Error("evidence path contains a NUL byte"); - } - const candidate = resolve(repositoryRoot, arguments_[1]); - const child = relative(evidenceRoot, candidate); - if ( - !child || - child === ".." || - child.startsWith(`..${sep}`) || - isAbsolute(child) || - extname(candidate).toLowerCase() !== ".json" - ) { - throw new Error("--evidence must name a .json file below benchmarks/evidence/"); - } - return candidate; -} - -function run(command, commandArguments, options = {}) { - const result = spawnSync(command, commandArguments, { - cwd: repositoryRoot, - encoding: "utf8", - windowsHide: true, - ...options, - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error( - `${command} ${commandArguments.join(" ")} failed with exit code ${result.status}\n${result.stderr}`, - ); - } - return result; -} - -function probeCommand(command, commandArguments) { - const result = spawnSync(command, commandArguments, { - cwd: repositoryRoot, - encoding: "utf8", - windowsHide: true, - }); - const source = `${command} ${commandArguments.join(" ")}`; - if (result.error) { - return { - status: result.error.code === "ENOENT" ? "unavailable" : "error", - source, - value: null, - detail: result.error.message, - }; - } - if (result.status !== 0) { - return { - status: "error", - source, - value: null, - exit_code: result.status, - detail: `${result.stderr ?? ""}`.trim().slice(0, 2_048) || null, - }; - } - const value = `${result.stdout ?? ""}`.trim().replaceAll("\r\n", "\n"); - return { - status: value ? "recorded" : "empty", - source, - value: value || null, - }; -} - -function sha256(bytes) { - return createHash("sha256").update(bytes).digest("hex"); -} - -function round(value) { - return Number(value.toFixed(3)); -} - -function percentile(sorted, percentage) { - const index = Math.ceil((percentage / 100) * sorted.length) - 1; - return sorted[Math.max(0, Math.min(index, sorted.length - 1))]; -} - -function conventionalMedian(sorted) { - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 - ? (sorted[middle - 1] + sorted[middle]) / 2 - : sorted[middle]; -} - -function summarize(samples) { - const sorted = [...samples].sort((left, right) => left - right); - const mean = sorted.reduce((sum, sample) => sum + sample, 0) / sorted.length; - const median = conventionalMedian(sorted); - const absoluteDeviations = sorted - .map((sample) => Math.abs(sample - median)) - .sort((left, right) => left - right); - const variance = - sorted.reduce((sum, sample) => sum + (sample - mean) ** 2, 0) / sorted.length; - return { - runs: sorted.length, - min_ms: round(sorted[0]), - median_ms: round(median), - mean_ms: round(mean), - p95_ms: round(percentile(sorted, 95)), - max_ms: round(sorted.at(-1)), - median_absolute_deviation_ms: round(conventionalMedian(absoluteDeviations)), - standard_deviation_ms: round(Math.sqrt(variance)), - samples_ms: samples.map(round), - }; -} - -function verifySummaryContract() { - const even = summarize([4, 1, 3, 2]); - const odd = summarize([3, 1, 2]); - if ( - even.median_ms !== 2.5 || - even.median_absolute_deviation_ms !== 1 || - odd.median_ms !== 2 || - odd.median_absolute_deviation_ms !== 1 - ) { - throw new Error("benchmark summary contract is not conventional median/MAD"); - } -} - -verifySummaryContract(); - -function toolVersion(command, commandArguments) { - return run(command, commandArguments).stdout.trim(); -} - -function captureToolchain() { - const cargo = toolVersion("cargo", ["+1.96.0", "--version"]); - const rustc = toolVersion("rustc", ["+1.96.0", "--version"]); - const rustcVerbose = toolVersion("rustc", ["+1.96.0", "-vV"]); - if (!/^cargo 1\.96\.0\b/u.test(cargo) || !/^rustc 1\.96\.0\b/u.test(rustc)) { - throw new Error(`benchmark requires exact Cargo/rustc 1.96.0; found ${cargo} / ${rustc}`); - } - const hostTarget = /^host: (\S+)$/mu.exec(rustcVerbose)?.[1]; - if (!hostTarget) { - throw new Error(`cannot determine the Rust 1.96.0 host target from:\n${rustcVerbose}`); - } - return { cargo, rustc, host_target: hostTarget }; -} - -function captureGitState() { - const commit = toolVersion("git", ["rev-parse", "HEAD"]); - if (!/^[0-9a-f]{40}$/u.test(commit)) { - throw new Error(`unexpected Git commit identity: ${commit}`); - } - const status = run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).stdout; - const entries = status.split("\0").filter(Boolean); - return { - commit, - dirty: entries.length > 0, - status_entry_count: entries.length, - status_sha256: sha256(Buffer.from(status, "utf8")), - }; -} - -function trackedInputProvenance(commit, absolutePath, bytes) { - const path = relative(repositoryRoot, absolutePath).replaceAll("\\", "/"); - if (!path || path.startsWith("../") || isAbsolute(path)) { - throw new Error(`tracked benchmark input escapes the repository: ${absolutePath}`); - } - if (evidencePath) { - const result = spawnSync("git", ["show", "--no-textconv", `${commit}:${path}`], { - cwd: repositoryRoot, - encoding: null, - maxBuffer: 16 * 1024 * 1024, - windowsHide: true, - }); - if (result.error) { - throw new Error(`cannot read tracked benchmark input ${commit}:${path}: ${result.error.message}`); - } - if (result.status !== 0) { - throw new Error( - `cannot read tracked benchmark input ${commit}:${path}: ${result.stderr.toString("utf8").trim()}`, - ); - } - if (!result.stdout.equals(bytes)) { - throw new Error( - `benchmark input ${path} differs byte-for-byte from ${commit}; normalize or restore it before recording evidence`, - ); - } - } - return { path, sha256: sha256(bytes) }; -} - -function securityToolingMetadata() { - const reportedStatus = process.env.PLIEGO_BENCH_SECURITY_TOOLING_STATUS?.trim().toLowerCase(); - const tooling = process.env.PLIEGO_BENCH_SECURITY_TOOLING?.trim() || null; - if (!reportedStatus) { - if (tooling) { - throw new Error( - "PLIEGO_BENCH_SECURITY_TOOLING requires PLIEGO_BENCH_SECURITY_TOOLING_STATUS", - ); - } - if (evidencePath) { - throw new Error("--evidence requires PLIEGO_BENCH_SECURITY_TOOLING_STATUS"); - } - return { - status: "not-reported", - tooling: null, - source: "PLIEGO_BENCH_SECURITY_TOOLING_STATUS/PLIEGO_BENCH_SECURITY_TOOLING", - }; - } - const allowed = new Set(["active", "disabled", "not-installed", "unknown"]); - if (!allowed.has(reportedStatus)) { - throw new Error( - "PLIEGO_BENCH_SECURITY_TOOLING_STATUS must be active, disabled, not-installed, or unknown", - ); - } - if ((reportedStatus === "active" || reportedStatus === "disabled") && !tooling) { - throw new Error(`${reportedStatus} security tooling requires PLIEGO_BENCH_SECURITY_TOOLING`); - } - return { - status: reportedStatus, - tooling, - source: "environment", - }; -} - -async function powerPlanMetadata() { - const declared = process.env.PLIEGO_BENCH_POWER_PLAN?.trim(); - if (declared) { - return { status: "declared", source: "PLIEGO_BENCH_POWER_PLAN", value: declared }; - } - if (process.platform === "win32") { - return probeCommand("powercfg", ["/getactivescheme"]); - } - if (process.platform === "darwin") { - return probeCommand("pmset", ["-g"]); - } - if (process.platform === "linux") { - const profile = probeCommand("powerprofilesctl", ["get"]); - if (profile.status === "recorded") { - return profile; - } - const governorPath = "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"; - try { - const governor = (await readFile(governorPath, "utf8")).trim(); - return { - status: governor ? "recorded" : "empty", - source: governorPath, - value: governor || null, - fallback_from: profile, - }; - } catch (error) { - return { - status: error.code === "ENOENT" ? "unavailable" : "error", - source: "powerprofilesctl/sysfs", - value: null, - detail: error.message, - powerprofilesctl: profile, - }; - } - } - return { - status: "unsupported", - source: null, - value: null, - detail: `no power-plan probe for ${process.platform}`, - }; -} - -async function captureEnvironment(toolchain) { - const cpuRecords = cpus(); - return { - os: { - platform: process.platform, - type: osType(), - release: osRelease(), - version: osVersion(), - architecture: process.arch, - }, - cpu: { - models: [...new Set(cpuRecords.map((cpu) => cpu.model))], - logical_core_count: cpuRecords.length, - available_parallelism: availableParallelism(), - }, - memory: { total_bytes: totalmem() }, - node: process.version, - zlib: process.versions.zlib, - toolchain, - build: { - host_target: toolchain.host_target, - cargo_target_dir: relative(repositoryRoot, cargoTargetDirectory).replaceAll("\\", "/"), - cargo_incremental: false, - cargo_home: relative(repositoryRoot, cargoHomeDirectory).replaceAll("\\", "/"), - cargo_home_policy: "isolated benchmark directory without user Cargo configuration", - sanitized_environment_keys: sanitizedCargoEnvironmentKeys(), - }, - power_plan: await powerPlanMetadata(), - security_tooling: securityToolingMetadata(), - }; -} - -async function prepareCargoHome() { - await mkdir(cargoHomeDirectory, { recursive: true }); - const info = await lstat(cargoHomeDirectory); - const targetRoot = await realpath(join(repositoryRoot, "target")); - if ( - !info.isDirectory() || - info.isSymbolicLink() || - !pathIsWithin(targetRoot, await realpath(cargoHomeDirectory)) - ) { - throw new Error("benchmark CARGO_HOME must be a real directory below target/"); - } - for (const name of ["config", "config.toml"]) { - if (await lstatOrNull(join(cargoHomeDirectory, name))) { - throw new Error(`benchmark CARGO_HOME must not contain user configuration: ${name}`); - } - } -} - -async function acquireRunLock() { - await mkdir(dirname(runLockPath), { recursive: true }); - const token = randomUUID(); - let handle; - try { - handle = await open(runLockPath, "wx", 0o600); - await handle.writeFile( - `${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), token })}\n`, - "utf8", - ); - } catch (error) { - if (handle) { - await handle.close(); - await rm(runLockPath, { force: true }); - } - if (error.code === "EEXIST") { - throw new Error(`another Gate B benchmark may be running: ${runLockPath}`); - } - throw error; - } - return { handle, token }; -} - -async function releaseRunLock(lock) { - const current = JSON.parse(await readFile(runLockPath, "utf8")); - await lock.handle.close(); - if (current.token !== lock.token) { - throw new Error(`Gate B run lock ownership changed: ${runLockPath}`); - } - await rm(runLockPath); -} - -function pathIsWithin(root, candidate) { - const child = relative(root, candidate); - return child === "" || (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child)); -} - -async function lstatOrNull(path) { - try { - return await lstat(path); - } catch (error) { - if (error.code === "ENOENT") { - return null; - } - throw error; - } -} - -async function prepareEvidenceDestination(destination) { - if (!destination) { - return; - } - await mkdir(evidenceRoot, { recursive: true }); - const rootInfo = await lstat(evidenceRoot); - if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) { - throw new Error("benchmarks/evidence must be a real directory, not a link"); - } - const physicalRoot = await realpath(evidenceRoot); - const parts = relative(evidenceRoot, destination).split(sep); - let current = evidenceRoot; - for (const part of parts.slice(0, -1)) { - current = join(current, part); - let info = await lstatOrNull(current); - if (!info) { - await mkdir(current); - info = await lstat(current); - } - if (!info.isDirectory() || info.isSymbolicLink()) { - throw new Error(`evidence parent is not a real directory: ${current}`); - } - const physical = await realpath(current); - if (!pathIsWithin(physicalRoot, physical)) { - throw new Error(`evidence parent escapes benchmarks/evidence: ${current}`); - } - } - if (await lstatOrNull(destination)) { - throw new Error(`evidence snapshots are immutable and already exist: ${destination}`); - } -} - -async function safelyRemoveWorkspace(path, prefix) { - const temporaryRoot = resolve(tmpdir()); - const candidate = resolve(path); - const child = relative(temporaryRoot, candidate); - if ( - !child || - child === ".." || - child.startsWith(`..${sep}`) || - isAbsolute(child) || - !basename(candidate).startsWith(prefix) - ) { - throw new Error(`refusing to remove unsafe benchmark workspace: ${candidate}`); - } - await rm(candidate, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); -} - -function sizeReport(bytes, cssGzipBytes) { - const gzipBytes = gzipSync(bytes, { level: 9 }).byteLength; - return { - raw_bytes: bytes.byteLength, - gzip_bytes: gzipBytes, - transfer_html_css_gzip_bytes: gzipBytes + cssGzipBytes, - sha256: sha256(bytes), - }; -} - -function percentChange(reference, candidate) { - return round(((candidate - reference) / reference) * 100); -} - -function replaceClassValues(html, classesBySource) { - let replacements = 0; - const rendered = html.replace(/class="([^"]*)"/gu, (_, source) => { - const className = classesBySource.get(source); - if (typeof className !== "string") { - throw new Error(`manifest has no className for class attribute: ${source}`); - } - replacements += 1; - return `class="${className}"`; - }); - if (replacements !== expectedClassAttributeCount) { - throw new Error( - `expected ${expectedClassAttributeCount} class replacements, performed ${replacements}`, - ); - } - const withoutClassValues = (value) => value.replace(/class="[^"]*"/gu, 'class=""'); - if (withoutClassValues(html) !== withoutClassValues(rendered)) { - throw new Error("generated HTML changed bytes outside class attribute values"); - } - return { bytes: Buffer.from(rendered, "utf8"), replacements }; -} - -const runLock = await acquireRunLock(); -try { -const harnessBytesBefore = await readFile(harnessPath); -const gitStateBefore = captureGitState(); -if (evidencePath && gitStateBefore.dirty) { - throw new Error("--evidence requires a clean Git worktree"); -} -const harnessProvenance = trackedInputProvenance( - gitStateBefore.commit, - harnessPath, - harnessBytesBefore, -); -const toolchain = captureToolchain(); -const environment = await captureEnvironment(toolchain); -const cargoEnvironment = controlledCargoEnvironment(); -const executablePath = join( - cargoTargetDirectory, - toolchain.host_target, - "release", - process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", -); -await prepareEvidenceDestination(evidencePath); -await prepareCargoHome(); - -console.log("Building pliego-cssc in release mode (excluded from timings)..."); -run( - "cargo", - [ - "+1.96.0", - "build", - "--release", - "--locked", - "--target", - toolchain.host_target, - "-p", - "pliego-cssc", - ], - { env: cargoEnvironment }, -); - -const workspace = await mkdtemp(join(tmpdir(), "pliego-gate-b-")); -try { - const fixtureBefore = await readFile(fixturePath); - const fixtureProvenance = trackedInputProvenance( - gitStateBefore.commit, - fixturePath, - fixtureBefore, - ); - const fixtureHtml = fixtureBefore.toString("utf8"); - const classValues = [...fixtureHtml.matchAll(/class="([^"]*)"/gu)].map( - (match) => match[1], - ); - if (classValues.length !== expectedClassAttributeCount) { - throw new Error( - `complete fixture must contain ${expectedClassAttributeCount} class attributes, found ${classValues.length}`, - ); - } - - const uniqueStyleInputs = new Set(classValues); - const utilityTokens = classValues.flatMap((value) => value.split(/\s+/u)); - const variants = [...new Set( - utilityTokens.flatMap((token) => { - const segments = token.split(":"); - return segments.length > 1 ? segments.slice(0, -1) : []; - }), - )].sort(); - const missingVariants = requiredVariants.filter((variant) => !variants.includes(variant)); - if (missingVariants.length > 0) { - throw new Error(`complete fixture is missing required variants: ${missingVariants.join(", ")}`); - } - - const inputPath = join(workspace, "complete-styles.txt"); - const inputBytes = Buffer.from(`${classValues.join("\n")}\n`, "utf8"); - await writeFile(inputPath, inputBytes); - - const warmupOutputPath = join(workspace, "warmup.css"); - for (let index = 0; index < warmupProcessCount; index += 1) { - run(executablePath, [ - "compile", - "--input", - inputPath, - "--seed", - "--theme", - "--output", - warmupOutputPath, - ]); - } - - const elapsedMilliseconds = []; - const outputs = []; - const hashes = []; - for (let index = 0; index < measuredProcessCount; index += 1) { - const outputPath = join(workspace, `gate-b-${index}.css`); - const startedAt = process.hrtime.bigint(); - run(executablePath, [ - "compile", - "--input", - inputPath, - "--seed", - "--theme", - "--output", - outputPath, - ]); - const stoppedAt = process.hrtime.bigint(); - const css = await readFile(outputPath); - elapsedMilliseconds.push(Number(stoppedAt - startedAt) / 1_000_000); - outputs.push(css); - hashes.push(sha256(css)); - } - - const uniqueHashes = [...new Set(hashes)]; - if (uniqueHashes.length !== 1) { - throw new Error(`non-deterministic output: observed ${uniqueHashes.length} SHA-256 hashes`); - } - const fixtureAfter = await readFile(fixturePath); - if (!fixtureBefore.equals(fixtureAfter)) { - throw new Error("tracked complete fixture changed during measurement"); - } - - const css = outputs[0]; - const cssText = css.toString("utf8"); - const missingEmittedVariants = Object.entries(emittedVariantMarkers) - .filter(([, marker]) => !cssText.includes(marker)) - .map(([variant]) => variant); - if (missingEmittedVariants.length > 0) { - throw new Error( - `compiled CSS is missing markers for variants: ${missingEmittedVariants.join(", ")}`, - ); - } - const compressedCss = gzipSync(css, { level: 9 }); - const manifestPath = join(workspace, "manifest.json"); - const manifestOutputPath = join(workspace, "manifest.css"); - run(executablePath, [ - "compile", - "--input", - inputPath, - "--seed", - "--theme", - "--output", - manifestOutputPath, - "--manifest", - manifestPath, - ]); - const manifestBytes = await readFile(manifestPath); - const manifestCss = await readFile(manifestOutputPath); - if (!css.equals(manifestCss)) { - throw new Error("manifest generation changed the compiled CSS artifact"); - } - const manifest = JSON.parse(manifestBytes.toString("utf8")); - if ( - manifest.schemaVersion !== 3 || - manifest.styleIdFormatVersion !== 2 || - manifest.classNameFormatVersion !== 1 || - manifest.themeIdFormatVersion !== 2 - ) { - throw new Error( - `expected manifest schema/identity formats 3/2/1/2, received ${manifest.schemaVersion}/${manifest.styleIdFormatVersion}/${manifest.classNameFormatVersion}/${manifest.themeIdFormatVersion}`, - ); - } - const classesBySource = new Map(); - for (const style of manifest.styles) { - for (const origin of style.origins) { - const existing = classesBySource.get(origin.source); - if (existing && existing !== style.className) { - throw new Error(`manifest maps ${origin.source} to multiple classes`); - } - classesBySource.set(origin.source, style.className); - } - } - if (classesBySource.size !== uniqueStyleInputs.size) { - throw new Error( - `expected ${uniqueStyleInputs.size} manifest styles, received ${classesBySource.size}`, - ); - } - if (new Set(classesBySource.values()).size !== classesBySource.size) { - throw new Error("manifest contains a generated class collision"); - } - - const generatedHtml = replaceClassValues(fixtureHtml, classesBySource); - const utilityHtmlReport = sizeReport(fixtureBefore, compressedCss.byteLength); - const generatedClassHtmlReport = sizeReport(generatedHtml.bytes, compressedCss.byteLength); - const timing = summarize(elapsedMilliseconds); - const output = { - css_raw_bytes: css.byteLength, - css_gzip_bytes: compressedCss.byteLength, - css_sha256: uniqueHashes[0], - executable_sha256: sha256(await readFile(executablePath)), - manifest_sha256: sha256(manifestBytes), - transfer_gzip_bytes: generatedClassHtmlReport.transfer_html_css_gzip_bytes, - html_unchanged_outside_class_values: true, - html: { - utility_strings: utilityHtmlReport, - generated_classes: generatedClassHtmlReport, - }, - }; - const comparisons = Object.fromEntries( - Object.entries(frozenTailwind.profiles).map(([profile, baseline]) => [ - profile, - { - median_time_change_percent: percentChange(baseline.median_ms, timing.median_ms), - median_speed_ratio: round(baseline.median_ms / timing.median_ms), - css_raw_change_percent: percentChange(baseline.css_raw_bytes, output.css_raw_bytes), - css_gzip_change_percent: percentChange(baseline.css_gzip_bytes, output.css_gzip_bytes), - transfer_gzip_change_percent: percentChange( - baseline.transfer_gzip_bytes, - output.transfer_gzip_bytes, - ), - }, - ]), - ); - const harnessBytesAfter = await readFile(harnessPath); - if (!harnessBytesBefore.equals(harnessBytesAfter)) { - throw new Error("benchmark harness changed during measurement"); - } - const gitStateAfter = captureGitState(); - if (JSON.stringify(gitStateBefore) !== JSON.stringify(gitStateAfter)) { - throw new Error("Git commit or worktree state changed during measurement"); - } - const result = { - schema_version: 3, - benchmark: "PliegoCSS Gate B complete-fixture fresh-process compile", - profile: "complete", - generated_at: new Date().toISOString(), - provenance: { - git: gitStateBefore, - harness: harnessProvenance, - tracked_inputs: [fixtureProvenance], - }, - fixture: { - html: "benchmarks/tailwind-v4/fixtures.html", - groups: ["button", "card", "navbar", "form", "dashboard"], - class_attribute_count: classValues.length, - class_attributes_resolved: generatedHtml.replacements, - all_class_attributes_resolved: generatedHtml.replacements === classValues.length, - unique_style_inputs: uniqueStyleInputs.size, - utility_occurrences: utilityTokens.length, - unique_utility_tokens: new Set(utilityTokens).size, - variants, - required_variants: requiredVariants, - emitted_variant_markers_verified: Object.keys(emittedVariantMarkers).sort(), - theme_included: true, - html_sha256: sha256(fixtureBefore), - derived_input_sha256: sha256(inputBytes), - }, - environment, - command: - "pliego-cssc compile --input --seed --theme --output ", - statistics: { - median: "average of the two middle values for even samples", - p95: "nearest-rank", - median_absolute_deviation: "conventional median of absolute deviations from the median", - }, - timing: { - warmup_processes: warmupProcessCount, - ...timing, - }, - determinism: { - verified_runs: measuredProcessCount, - deterministic: true, - sha256: uniqueHashes[0], - }, - output, - frozen_tailwind_baseline: frozenTailwind, - comparisons, - caveats: [ - "Fresh-process timings retain normal OS caches and exclude the release build.", - "Manifest generation and HTML rewriting are verified outside the timed region.", - "PliegoCSS does not emit reset/preflight; no-preflight-complete is the primary payload comparison.", - "The generated-class HTML and Tailwind utility HTML preserve the same DOM but have different class-value bytes.", - ], - }; - - const serializedResult = `${JSON.stringify(result, null, 2)}\n`; - await mkdir(dirname(resultsPath), { recursive: true }); - await writeFile(resultsPath, serializedResult, "utf8"); - if (evidencePath) { - await writeFile(evidencePath, serializedResult, { encoding: "utf8", flag: "wx" }); - } - - console.log( - `Gate B complete: ${generatedHtml.replacements}/${classValues.length} class attributes resolved (${uniqueStyleInputs.size} unique styles), ${warmupProcessCount} warmups + ${measuredProcessCount} measured fresh processes`, - ); - console.log(`Variants: ${variants.join(", ")}`); - console.log( - `Timing: min ${timing.min_ms} ms | median ${timing.median_ms} ms | p95 ${timing.p95_ms} ms | max ${timing.max_ms} ms`, - ); - console.log(`CSS: ${output.css_raw_bytes} B raw | ${output.css_gzip_bytes} B gzip`); - console.log( - `Generated HTML: ${generatedClassHtmlReport.raw_bytes} B raw | ${generatedClassHtmlReport.gzip_bytes} B gzip | ${generatedClassHtmlReport.transfer_html_css_gzip_bytes} B transfer`, - ); - console.log( - `Determinism: ${measuredProcessCount}/${measuredProcessCount} identical (${uniqueHashes[0]})`, - ); - console.log(`Results: ${resultsPath}`); - if (evidencePath) { - console.log(`Evidence: ${evidencePath}`); - } -} finally { - await safelyRemoveWorkspace(workspace, "pliego-gate-b-"); -} -} finally { - await releaseRunLock(runLock); -} +// Compatibility entry point. Gate B's handwritten Tailwind 4.3.2 baseline was superseded by the +// three-lane, paired Benchmark Authority v2 contract. Arguments are forwarded unchanged. +await import("./measure-benchmark-authority-v2.mjs"); diff --git a/scripts/measure-reachability-pruning.mjs b/scripts/measure-reachability-pruning.mjs index 36a929f..890cda8 100644 --- a/scripts/measure-reachability-pruning.mjs +++ b/scripts/measure-reachability-pruning.mjs @@ -15,10 +15,10 @@ const expected = { sourceSha256: "159233dbb9258f93bbc0561e795780aa3b803a2cab8bfa3b19d37284d40359c0", reachabilitySha256: "d457dd60004d9d989d42928c5665a5ed84561bf4955f61af3a23ecf21b876a76", baseline: { - cssSha256: "9bf886ca054ada11b15b3bbd1e705954ae6aa1d4cb06ec65c168bd46b1f510a7", - rawBytes: 7_286, - gzipBytes: 1_215, - manifestSha256: "7fd4798a648df834353862cef0054cdbe1cc1471429db7261c682342f1e641f7", + cssSha256: "a0d4a94b0be27ecbd113b764eca1d20ccbf5c328c896b3f2ca1a0f86516859ee", + rawBytes: 7_282, + gzipBytes: 1_213, + manifestSha256: "a9e293c0f64cfff3658f0ca8d041c9ce659427915bb0064401999bf7ff210b5a", styles: 14, declarations: 147, tokens: 30, @@ -26,10 +26,10 @@ const expected = { physicalDeclarations: 207, }, pruned: { - cssSha256: "ce92fd334f9defec213c7aad7e8b855b573fc883724b5c57a4df8cb96f559317", + cssSha256: "b1e26159f1078fd29403b5f70fb85fed7d1e290941dc07c91a476d96b317222a", rawBytes: 366, - gzipBytes: 225, - manifestSha256: "ef8622609fa44d9b6b55640ca2774bd1cb9125132463024d17d0564286aa08c3", + gzipBytes: 224, + manifestSha256: "8a991deb50fbb5299e3de3008297570bd14c62af2b47f47fe9449e344b7d241b", styles: 2, declarations: 13, tokens: 9, @@ -37,10 +37,10 @@ const expected = { physicalDeclarations: 15, }, themedBaseline: { - cssSha256: "e1f3d60c48f444e0a78e985f7632a8d2c9cb47c5500833c4df25a9cafc05162e", - rawBytes: 7_657, + cssSha256: "cd2a276e221155f29cfc12ff3dbd8394cd144e314ccc1a753ad097b1178cb70f", + rawBytes: 7_653, gzipBytes: 1_369, - manifestSha256: "58cb0152d35ee0569b6d0a8936ec3721018089ce436e1004c6b756fe288ca40d", + manifestSha256: "db2c50a01903add21dec541155781f7b64ba04509103f23bc73236aca7c5d7e4", styles: 14, declarations: 147, tokens: 30, @@ -60,10 +60,10 @@ const expected = { ], }, themedPruned: { - cssSha256: "ec4f3c40228ae18690b43bb7e1d6e4fcde7241a4ecc9842987659d594f320ee5", + cssSha256: "a3b4b91dad2f3e627f56d219cf476bd1dfb22b12c5669c8f26af469d380a7799", rawBytes: 457, - gzipBytes: 266, - manifestSha256: "b6f838baf3e444cddb8bc2daaf439f34cb3cdb5b626e672fdee966102b72966b", + gzipBytes: 265, + manifestSha256: "615fb6fe955d7950bcb58da700ad8b8fe676baeb93de409fa1e1991e10106eee", styles: 2, declarations: 13, tokens: 9, @@ -75,16 +75,16 @@ const expected = { const expectedGzipSha256ByZlib = { "1.3.0.1-motley-82a5fec": { - baseline: "67e3b128ee810beb6ce5c2cca5bbed3ab9e0544b373388db41bb56fe354a3707", - pruned: "c5923c28ff5234957276529060728513e31c1fb19ef3c61164a1c96e105df9d1", - themedBaseline: "64800d9e18bcf91aeebb029ccc32ca454c9a2d57c99241261270777956b8b87b", - themedPruned: "9f83be529c8822e54ac82a1794d7d5183a929407b6b3f1048ad6b7735c0ab60d", + baseline: "941b57383fd67450ae34234876b2b09d64dd6fb68dd138503858b9c4dfa5b4b1", + pruned: "4b62e9053298bcfb12e07330bac4c47c68a7c45815832d4d087f89e754a4501c", + themedBaseline: "a13dee3f2d2f5093c7b3564d17b6aad9869a05618fb887bd2c0b359dc4a57814", + themedPruned: "8670a47312abec7e2fe58770c2a9c136aa7529eac104923acc9c6efea4f0312c", }, "1.3.1-e00f703": { - baseline: "67e3b128ee810beb6ce5c2cca5bbed3ab9e0544b373388db41bb56fe354a3707", - pruned: "c5923c28ff5234957276529060728513e31c1fb19ef3c61164a1c96e105df9d1", - themedBaseline: "64800d9e18bcf91aeebb029ccc32ca454c9a2d57c99241261270777956b8b87b", - themedPruned: "9f83be529c8822e54ac82a1794d7d5183a929407b6b3f1048ad6b7735c0ab60d", + baseline: "941b57383fd67450ae34234876b2b09d64dd6fb68dd138503858b9c4dfa5b4b1", + pruned: "4b62e9053298bcfb12e07330bac4c47c68a7c45815832d4d087f89e754a4501c", + themedBaseline: "a13dee3f2d2f5093c7b3564d17b6aad9869a05618fb887bd2c0b359dc4a57814", + themedPruned: "8670a47312abec7e2fe58770c2a9c136aa7529eac104923acc9c6efea4f0312c", }, }; diff --git a/scripts/measure-wasm-overhead.mjs b/scripts/measure-wasm-overhead.mjs index 02bcb2e..abcb281 100644 --- a/scripts/measure-wasm-overhead.mjs +++ b/scripts/measure-wasm-overhead.mjs @@ -81,7 +81,7 @@ if ( manifest.schemaVersion !== 3 || manifest.styleIdFormatVersion !== 2 || manifest.classNameFormatVersion !== 1 || - manifest.themeIdFormatVersion !== 2 + manifest.themeIdFormatVersion !== 3 ) { throw new Error( `expected manifest schema/identity formats 3/2/1/2, received ${manifest.schemaVersion}/${manifest.styleIdFormatVersion}/${manifest.classNameFormatVersion}/${manifest.themeIdFormatVersion}`, diff --git a/scripts/pliegors-contract.mjs b/scripts/pliegors-contract.mjs index 23be9e3..6ba968c 100644 --- a/scripts/pliegors-contract.mjs +++ b/scripts/pliegors-contract.mjs @@ -12,6 +12,7 @@ export const PLIEGORS_CONTRACT_SURFACES = Object.freeze([ "crates/pliego-ssg/**", "crates/pliego-starters/**", "crates/pliego-cli/**", + "schemas/pliego.product-topology.schema.json", ]); function git(root, args, encoding = null) { diff --git a/scripts/prepare-external-adoption-record.mjs b/scripts/prepare-external-adoption-record.mjs new file mode 100644 index 0000000..66f0d67 --- /dev/null +++ b/scripts/prepare-external-adoption-record.mjs @@ -0,0 +1,182 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadExternalAdoptionBundle, + validateExternalAdoptionBundle, +} from "./external-adoption-v1.mjs"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const privateRoot = resolve( + repositoryRoot, + "benchmarks", + "external-adoption-v1", + "private", +); +const outputRoot = resolve(repositoryRoot, "target", "external-adoption-intake"); +const typeToLedger = Object.freeze({ + interview: "interviews", + incident: "incidents", + pilot: "pilots", +}); + +export class ExternalAdoptionPreparationError extends Error { + constructor(message) { + super(message); + this.name = "ExternalAdoptionPreparationError"; + } +} + +function fail(message) { + throw new ExternalAdoptionPreparationError(message); +} + +function within(base, candidate) { + const child = relative(base, candidate); + return child === "" || (!child.startsWith("..") && !isAbsolute(child)); +} + +function regularInput(value, role, maximumBytes) { + if (typeof value !== "string" || value === "") fail(`${role} path is required`); + const path = resolve(value); + if (!existsSync(path)) fail(`${role} does not exist`); + const metadata = lstatSync(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + fail(`${role} must be a regular non-link file`); + } + if (metadata.size === 0 || metadata.size > maximumBytes) { + fail(`${role} must contain 1..${maximumBytes} bytes`); + } + if (within(repositoryRoot, path) && !within(privateRoot, path)) { + fail(`${role} inside the repository must be under the ignored private custody directory`); + } + return { path, metadata }; +} + +function safeOutput(value) { + if (typeof value !== "string" || value === "") fail("output path is required"); + const path = resolve(value); + if (!within(outputRoot, path) || path === outputRoot) { + fail("output must be a file under target/external-adoption-intake"); + } + if (existsSync(path)) fail("output already exists"); + return path; +} + +function parseJson(bytes, role) { + let value; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch { + fail(`${role} must be one UTF-8 JSON object`); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${role} must be one JSON object`); + } + return value; +} + +function rejectSensitivePublicRecord(record) { + const text = JSON.stringify(record); + const forbidden = [ + [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu, "email address"], + [/-----BEGIN [A-Z ]+PRIVATE KEY-----/u, "private key"], + [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/u, "GitHub token"], + [/\bAKIA[0-9A-Z]{16}\b/u, "AWS access key"], + [/\b[A-Za-z]:\\\\[^"]+/u, "absolute Windows path"], + ]; + for (const [pattern, label] of forbidden) { + if (pattern.test(text)) fail(`public record appears to contain a ${label}`); + } +} + +export function prepareExternalAdoptionRecord( + { + type, + source: sourceValue, + record: recordValue, + output: outputValue, + }, + { root = repositoryRoot } = {}, +) { + if (root !== repositoryRoot) { + fail("preparation root must be the PliegoCSS repository"); + } + const ledger = typeToLedger[type]; + if (!ledger) fail("type must be interview, incident, or pilot"); + const source = regularInput(sourceValue, "private source", 10 * 1024 * 1024); + const recordInput = regularInput(recordValue, "redacted record", 256 * 1024); + if (source.path === recordInput.path) fail("private source and redacted record must be different files"); + const output = safeOutput(outputValue); + + const sourceBytes = readFileSync(source.path); + const sourceSha256 = `sha256:${createHash("sha256").update(sourceBytes).digest("hex")}`; + const record = parseJson(readFileSync(recordInput.path), "redacted record"); + if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) { + fail("redacted record.source must be an object"); + } + record.source.sha256 = sourceSha256; + if ( + type !== "pilot" && + (typeof record.source.custodyRef !== "string" || + !record.source.custodyRef.startsWith(`private:g7/${ledger}/`)) + ) { + fail(`redacted record.source.custodyRef must start with private:g7/${ledger}/`); + } + rejectSensitivePublicRecord(record); + + const bundle = loadExternalAdoptionBundle(root); + bundle[ledger].records.push(record); + validateExternalAdoptionBundle(bundle, { root, verifyFiles: false }); + + mkdirSync(dirname(output), { recursive: true }); + const bytes = `${JSON.stringify(record, null, 2)}\n`; + writeFileSync(output, bytes, { encoding: "utf8", flag: "wx" }); + return { + schemaVersion: 1, + kind: "pliegocss-external-adoption-preparation", + type, + id: record.id, + sourceBytes: source.metadata.size, + sourceSha256, + output, + emittedBytes: Buffer.byteLength(bytes), + }; +} + +export function parsePreparationArguments(args) { + const values = {}; + for (const argument of args) { + const match = /^--(type|source|record|output)=(.+)$/u.exec(argument); + if (!match || match[1] in values) fail(`unknown or duplicate argument ${argument}`); + values[match[1]] = match[2]; + } + if (Object.keys(values).length !== 4) { + fail( + "usage: prepare-external-adoption-record --type= --source= --record= --output=", + ); + } + return values; +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) +) { + try { + const result = prepareExternalAdoptionRecord( + parsePreparationArguments(process.argv.slice(2)), + ); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } catch (error) { + process.stderr.write(`external adoption preparation: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/prepare-external-adoption-record.test.mjs b/scripts/prepare-external-adoption-record.test.mjs new file mode 100644 index 0000000..463dedf --- /dev/null +++ b/scripts/prepare-external-adoption-record.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { + ExternalAdoptionPreparationError, + prepareExternalAdoptionRecord, +} from "./prepare-external-adoption-record.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const privateBase = resolve( + root, + "benchmarks", + "external-adoption-v1", + "private", +); +const outputBase = resolve(root, "target", "external-adoption-intake"); +mkdirSync(privateBase, { recursive: true }); +mkdirSync(outputBase, { recursive: true }); +const privateDirectory = mkdtempSync(join(privateBase, "preparation-test-")); +const outputDirectory = mkdtempSync(join(outputBase, "preparation-test-")); + +try { + const sourcePath = join(privateDirectory, "source.bin"); + const recordPath = join(privateDirectory, "record.json"); + const outputPath = join(outputDirectory, "interview.json"); + const privateBytes = Buffer.from( + "PRIVATE INTERVIEW MATERIAL: participant@example.invalid\n", + ); + writeFileSync(sourcePath, privateBytes); + writeFileSync( + recordPath, + `${JSON.stringify( + { + id: "interview-preparation-test", + participantId: "participant-preparation-test", + organizationId: "organization-preparation-test", + relationship: "external-community", + conductedAt: "2026-07-20T00:00:00Z", + consent: { + status: "recorded", + recordedAt: "2026-07-19T00:00:00Z", + researchUse: true, + publicRedactedRecord: true, + }, + redaction: { status: "reviewed", containsPersonalData: false }, + source: { + custodyRef: + "private:g7/interviews/interview-preparation-test", + sha256: "sha256:pending", + }, + review: { + reviewer: "reviewer-preparation-test", + reviewedAt: "2026-07-21T00:00:00Z", + status: "approved", + }, + findings: ["A reviewed redacted finding."], + relatedIncidentIds: [], + relatedPilotIds: [], + }, + null, + 2, + )}\n`, + ); + const result = prepareExternalAdoptionRecord({ + type: "interview", + source: sourcePath, + record: recordPath, + output: outputPath, + }); + const expectedDigest = `sha256:${createHash("sha256").update(privateBytes).digest("hex")}`; + assert.equal(result.sourceSha256, expectedDigest); + const emitted = readFileSync(outputPath, "utf8"); + assert.equal(JSON.parse(emitted).source.sha256, expectedDigest); + assert(!emitted.includes("PRIVATE INTERVIEW MATERIAL")); + assert(!emitted.includes("participant@example.invalid")); + assert(!emitted.includes(sourcePath)); + + assert.throws( + () => + prepareExternalAdoptionRecord({ + type: "interview", + source: resolve(root, "benchmarks", "external-adoption-v1", "authority.json"), + record: recordPath, + output: join(outputDirectory, "unsafe-source.json"), + }), + (error) => + error instanceof ExternalAdoptionPreparationError && + /ignored private custody directory/u.test(error.message), + ); + + const sensitiveRecordPath = join(privateDirectory, "sensitive-record.json"); + const sensitive = JSON.parse(readFileSync(recordPath, "utf8")); + sensitive.findings = ["Contact participant@example.invalid for details."]; + writeFileSync(sensitiveRecordPath, `${JSON.stringify(sensitive, null, 2)}\n`); + assert.throws( + () => + prepareExternalAdoptionRecord({ + type: "interview", + source: sourcePath, + record: sensitiveRecordPath, + output: join(outputDirectory, "sensitive.json"), + }), + (error) => + error instanceof ExternalAdoptionPreparationError && + /email address/u.test(error.message), + ); +} finally { + rmSync(privateDirectory, { recursive: true, force: true }); + rmSync(outputDirectory, { recursive: true, force: true }); +} + +process.stdout.write("external adoption record preparation: pass\n"); diff --git a/scripts/prepare-native-distribution.mjs b/scripts/prepare-native-distribution.mjs new file mode 100644 index 0000000..3326c36 --- /dev/null +++ b/scripts/prepare-native-distribution.mjs @@ -0,0 +1,124 @@ +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + ROOT, + assertChildPath, + exactKeys, + fail, + fileSha256, + repositoryVersion, + validateBinary, + validateCycloneDx, + validatePolicy, + validateSource, +} from "./repository-distribution.mjs"; + +function options(args) { + const parsed = {}; + for (const arg of args) { + if (arg === "--") continue; + const match = /^--([a-z-]+)=(.+)$/u.exec(arg); + if (!match || Object.hasOwn(parsed, match[1])) fail(`invalid option: ${arg}`); + parsed[match[1]] = match[2]; + } + return parsed; +} + +function git(args) { + const result = spawnSync("git", args, { + cwd: ROOT, + encoding: "utf8", + windowsHide: true, + }); + if (result.error || result.status !== 0) { + fail(`git ${args.join(" ")} failed: ${result.error?.message ?? result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +const input = options(process.argv.slice(2)); +const required = ["target", "binary-dir", "cli-sbom", "lsp-sbom", "output"]; +for (const name of required) { + if (!input[name]) fail(`missing --${name}`); +} +const policy = validatePolicy(); +const target = policy.targets.find((entry) => entry.id === input.target); +if (!target) fail(`unknown native target: ${input.target}`); +const version = repositoryVersion(); +const source = { + commit: input["source-commit"] ?? git(["rev-parse", "HEAD"]), + gitTree: input["git-tree"] ?? git(["show", "-s", "--format=%T", "HEAD"]), + tag: input.tag ?? null, +}; +validateSource(source); +if (source.tag !== null && source.tag !== `v${version}`) { + fail(`source tag ${source.tag} does not match package version ${version}`); +} + +const binaryDirectory = resolve(ROOT, input["binary-dir"]); +const output = assertChildPath( + resolve(ROOT, "target"), + resolve(ROOT, input.output), + "native output", +); +rmSync(output, { recursive: true, force: true }); +mkdirSync(output, { recursive: true }); + +const binaries = []; +for (const binaryName of policy.binaries) { + const filename = `${binaryName}${target.extension}`; + const sourcePath = resolve(binaryDirectory, filename); + const details = validateBinary(sourcePath, target); + const destination = resolve(output, filename); + copyFileSync(sourcePath, destination); + if (target.os !== "win32") chmodSync(destination, 0o755); + binaries.push({ name: binaryName, file: filename, ...details }); +} + +const sbomInputs = { + "pliego-cssc": resolve(ROOT, input["cli-sbom"]), + "pliego-css-lsp": resolve(ROOT, input["lsp-sbom"]), +}; +const sboms = []; +for (const binaryName of policy.binaries) { + const sourcePath = sbomInputs[binaryName]; + if (!existsSync(sourcePath)) fail(`SBOM is missing: ${sourcePath}`); + validateCycloneDx(sourcePath, binaryName, version); + const filename = `${binaryName}.cdx.json`; + const destination = resolve(output, filename); + copyFileSync(sourcePath, destination); + sboms.push({ binary: binaryName, file: filename, sha256: fileSha256(destination) }); +} + +copyFileSync(resolve(ROOT, "LICENSE"), resolve(output, "LICENSE")); +const receipt = { + schemaVersion: 1, + kind: "pliegocss-native-distribution-input", + version, + source, + target: { + id: target.id, + os: target.os, + arch: target.arch, + format: target.format, + }, + binaries, + sboms, +}; +exactKeys( + receipt, + ["schemaVersion", "kind", "version", "source", "target", "binaries", "sboms"], + "native receipt", +); +writeFileSync(resolve(output, "native.json"), `${JSON.stringify(receipt, null, 2)}\n`); +process.stdout.write( + `${JSON.stringify({ target: target.id, output, files: binaries.length + sboms.length + 2 })}\n`, +); diff --git a/scripts/release-authority.mjs b/scripts/release-authority.mjs new file mode 100644 index 0000000..052b055 --- /dev/null +++ b/scripts/release-authority.mjs @@ -0,0 +1,421 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +const COMMIT = /^[0-9a-f]{40}$/u; +const DIGEST = /^sha256:[0-9a-f]{64}$/u; +const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u; + +export class AuthorityError extends Error { + constructor(message) { + super(message); + this.name = "AuthorityError"; + } +} + +function fail(message) { + throw new AuthorityError(message); +} + +function object(value, role) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${role} must be an object`); + } + return value; +} + +function exactKeys(value, keys, role) { + object(value, role); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(`${role} fields drifted: ${actual.join(", ")}`); + } +} + +function strings(value, role) { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry)) { + fail(`${role} must be an array of non-empty strings`); + } + if (new Set(value).size !== value.length) fail(`${role} contains duplicates`); + return value; +} + +function instant(value, role) { + if (typeof value !== "string" || !INSTANT.test(value) || Number.isNaN(Date.parse(value))) { + fail(`${role} must be a canonical UTC second instant`); + } + return Date.parse(value); +} + +function source(value, role, { tag = false } = {}) { + const keys = tag ? ["tag", "commit", "gitTree"] : ["commit", "gitTree"]; + exactKeys(value, keys, role); + if (!COMMIT.test(value.commit) || !COMMIT.test(value.gitTree)) { + fail(`${role} commit and Git tree must be full lowercase object IDs`); + } + if (tag && !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(value.tag)) { + fail(`${role}.tag is not a release tag`); + } + return value; +} + +function sourceEquals(left, right) { + return left.commit === right.commit && left.gitTree === right.gitTree; +} + +function localPath(root, value, role) { + if ( + typeof value !== "string" || + !value || + isAbsolute(value) || + value.includes("\\") || + value.split("/").includes("..") + ) { + fail(`${role} must be a portable repository-relative path`); + } + const absolute = resolve(root, value); + const child = relative(root, absolute); + if (!child || child.startsWith("..") || isAbsolute(child)) { + fail(`${role} escapes the repository`); + } + if (!existsSync(absolute)) fail(`${role} does not exist: ${value}`); + return absolute; +} + +function artifact(root, value, role) { + exactKeys(value, ["path", "url", "sha256"], role); + if (typeof value.url !== "string" || !value.url.startsWith("https://")) { + fail(`${role}.url must use HTTPS`); + } + if (!DIGEST.test(value.sha256)) fail(`${role}.sha256 is not canonical`); + const path = localPath(root, value.path, `${role}.path`); + const actual = `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; + if (actual !== value.sha256) fail(`${role} hash drifted for ${value.path}`); +} + +function reference(value, role) { + exactKeys(value, ["kind", "url"], role); + if (typeof value.kind !== "string" || !value.kind) fail(`${role}.kind is missing`); + if (typeof value.url !== "string" || !value.url.startsWith("https://")) { + fail(`${role}.url must use HTTPS`); + } +} + +function environment(value, role) { + exactKeys(value, ["os", "arch", "rust", "node", "browser"], role); + for (const key of Object.keys(value)) { + if (value[key] !== null && (typeof value[key] !== "string" || !value[key])) { + fail(`${role}.${key} must be a non-empty string or null`); + } + } +} + +function waiver(root, value, role) { + if (value === null) return; + const path = localPath(root, value, role); + const child = relative(resolve(root, "docs", "adr"), path); + if (!child || child.startsWith("..") || isAbsolute(child)) { + fail(`${role} must point inside docs/adr`); + } +} + +function verifyTag(root, value) { + const commands = [ + [["rev-parse", `${value.tag}^{commit}`], value.commit, "tag commit"], + [["show", "-s", "--format=%T", value.commit], value.gitTree, "Git tree"], + ]; + for (const [args, expected, role] of commands) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true }); + if (result.error || result.status !== 0) { + fail(`cannot verify ${role}: ${result.error?.message ?? result.stderr.trim()}`); + } + if (result.stdout.trim() !== expected) fail(`${role} does not match the repository`); + } +} + +function validWindow(observedAt, expiresAt, role, now) { + const observed = instant(observedAt, `${role}.observedAt`); + const expires = instant(expiresAt, `${role}.expiresAt`); + if (expires <= observed) fail(`${role} expires before it is observed`); + return now <= expires; +} + +function completeCoverage(required, covered) { + const actual = new Set(covered); + return required.every((item) => actual.has(item)); +} + +export function validateReleaseReadiness(value, { root, now = Date.now(), verifyGit = true }) { + exactKeys( + value, + [ + "schemaVersion", + "kind", + "state", + "targetVersion", + "candidateVersion", + "repository", + "productStage", + "source", + "observedAt", + "expiresAt", + "result", + "dimensions", + "checks", + "promotionRule", + ], + "readiness", + ); + if ( + value.schemaVersion !== 3 || + value.kind !== "pliegocss-release-readiness" || + value.state !== "current" || + value.targetVersion !== "0.1.0" || + value.candidateVersion !== "0.1.0-rc.2" || + value.repository !== "https://github.com/celiumsai/pliegocss" || + value.productStage !== "public-preview" + ) { + fail("readiness header drifted"); + } + const rootSource = source(value.source, "readiness.source", { tag: true }); + if (verifyGit) verifyTag(root, rootSource); + validWindow(value.observedAt, value.expiresAt, "readiness", now); + exactKeys( + value.dimensions, + ["technicalReady", "operationallyReady", "adoptionReady", "authorized", "promotionReady"], + "readiness.dimensions", + ); + if (!Array.isArray(value.checks)) fail("readiness.checks must be an array"); + const requiredIds = new Set([ + "exact-candidate-ci", + "exact-candidate-codeql", + "g0-corrections-on-candidate", + "browser-release-matrix", + "adapter-coexistence-matrix", + "registry-replay-refresh", + "production-deployment-refresh", + "signed-binary-distribution", + "external-adoption-evidence", + "final-promotion-authorization", + ]); + const ids = value.checks.map((check) => check.id); + if (new Set(ids).size !== ids.length) fail("readiness check IDs are not unique"); + if (ids.length !== requiredIds.size || ids.some((id) => !requiredIds.has(id))) { + fail("readiness check inventory drifted"); + } + const allowedDimensions = new Set(["technical", "operational", "adoption", "authorization"]); + const allowedStatuses = new Set(["passed", "pending", "blocked", "not-authorized", "not-applicable"]); + const allowedEvidence = new Set(["measured", "inherited", "pending", "uncertain"]); + for (const check of value.checks) { + const role = `readiness.checks.${check.id}`; + exactKeys( + check, + [ + "id", + "dimension", + "status", + "evidenceClass", + "requiredForPromotion", + "summary", + "source", + "observedAt", + "expiresAt", + "environment", + "requiredCoverage", + "coveredCoverage", + "waiverAdr", + "artifacts", + "references", + ], + role, + ); + if (!allowedDimensions.has(check.dimension)) fail(`${role}.dimension is invalid`); + if (!allowedStatuses.has(check.status)) fail(`${role}.status is invalid`); + if (!allowedEvidence.has(check.evidenceClass)) fail(`${role}.evidenceClass is invalid`); + if (typeof check.requiredForPromotion !== "boolean") fail(`${role}.requiredForPromotion must be boolean`); + if (typeof check.summary !== "string" || !check.summary) fail(`${role}.summary is missing`); + const checkSource = source(check.source, `${role}.source`); + if (!sourceEquals(checkSource, rootSource)) fail(`${role} is not bound to the candidate source`); + const unexpired = validWindow(check.observedAt, check.expiresAt, role, now); + environment(check.environment, `${role}.environment`); + const required = strings(check.requiredCoverage, `${role}.requiredCoverage`); + const covered = strings(check.coveredCoverage, `${role}.coveredCoverage`); + waiver(root, check.waiverAdr, `${role}.waiverAdr`); + if (!Array.isArray(check.artifacts)) fail(`${role}.artifacts must be an array`); + check.artifacts.forEach((entry, index) => artifact(root, entry, `${role}.artifacts[${index}]`)); + if (!Array.isArray(check.references)) fail(`${role}.references must be an array`); + check.references.forEach((entry, index) => reference(entry, `${role}.references[${index}]`)); + if (check.status === "passed") { + if (check.evidenceClass !== "measured") fail(`${role} passed without measured evidence`); + if (!unexpired) fail(`${role} passed on expired evidence`); + if (!completeCoverage(required, covered)) fail(`${role} passed with incomplete coverage`); + if (check.waiverAdr !== null) fail(`${role} passed through a waiver`); + if (check.artifacts.length === 0) fail(`${role} passed without a hashed artifact`); + if (check.references.length === 0) fail(`${role} passed without a source URL`); + } + } + const dimension = (name) => + value.checks + .filter((check) => check.requiredForPromotion && check.dimension === name) + .every((check) => check.status === "passed"); + const derived = { + technicalReady: dimension("technical"), + operationallyReady: dimension("operational"), + adoptionReady: dimension("adoption"), + authorized: dimension("authorization"), + }; + derived.promotionReady = Object.values(derived).every(Boolean); + if (JSON.stringify(value.dimensions) !== JSON.stringify(derived)) { + fail("readiness dimensions do not match the required checks"); + } + const result = derived.promotionReady ? "ready" : "blocked"; + if (value.result !== result) fail("readiness result does not match its dimensions"); + if (typeof value.promotionRule !== "string" || !value.promotionRule) { + fail("readiness promotion rule is missing"); + } + return { + schemaVersion: value.schemaVersion, + candidate: value.candidateVersion, + sourceCommit: rootSource.commit, + gitTree: rootSource.gitTree, + result, + dimensions: derived, + blockers: value.checks + .filter((check) => check.requiredForPromotion && check.status !== "passed") + .map((check) => check.id), + }; +} + +export function validateBrowserMatrix(value, { root, now = Date.now(), verifyGit = true }) { + exactKeys( + value, + [ + "schemaVersion", + "kind", + "state", + "source", + "observedAt", + "expiresAt", + "profiles", + "hosts", + "result", + "missingRequiredHosts", + "releaseBoundary", + ], + "browserMatrix", + ); + if ( + value.schemaVersion !== 2 || + value.kind !== "pliegocss-hosted-browser-matrix" || + value.state !== "current" + ) { + fail("browser matrix header drifted"); + } + const rootSource = source(value.source, "browserMatrix.source", { tag: true }); + if (verifyGit) verifyTag(root, rootSource); + validWindow(value.observedAt, value.expiresAt, "browserMatrix", now); + exactKeys(value.profiles, ["release"], "browserMatrix.profiles"); + exactKeys( + value.profiles.release, + ["requiredHosts", "allowNotConfigured", "allowStale", "allowWaivers"], + "browserMatrix.profiles.release", + ); + const requiredHosts = strings( + value.profiles.release.requiredHosts, + "browserMatrix.profiles.release.requiredHosts", + ); + if ( + value.profiles.release.allowNotConfigured !== false || + value.profiles.release.allowStale !== false || + value.profiles.release.allowWaivers !== false + ) { + fail("release browser profile must fail closed"); + } + if (!Array.isArray(value.hosts)) fail("browserMatrix.hosts must be an array"); + const ids = value.hosts.map((host) => host.id); + if (new Set(ids).size !== ids.length) fail("browser host IDs are not unique"); + if (ids.length !== requiredHosts.length || requiredHosts.some((id) => !ids.includes(id))) { + fail("browser host inventory does not match release coverage"); + } + const allowedStatuses = new Set(["passed", "failed", "not-configured", "stale"]); + for (const host of value.hosts) { + const role = `browserMatrix.hosts.${host.id}`; + exactKeys( + host, + [ + "id", + "os", + "arch", + "browser", + "browserVersion", + "status", + "sourceCommit", + "gitTree", + "observedAt", + "expiresAt", + "automation", + "requiredCoverage", + "coveredCoverage", + "waiverAdr", + "artifacts", + ], + role, + ); + for (const key of ["id", "os", "arch", "browser", "automation"]) { + if (typeof host[key] !== "string" || !host[key]) fail(`${role}.${key} is missing`); + } + if (!allowedStatuses.has(host.status)) fail(`${role}.status is invalid`); + if (host.sourceCommit !== rootSource.commit || host.gitTree !== rootSource.gitTree) { + fail(`${role} is not bound to the candidate source`); + } + const required = strings(host.requiredCoverage, `${role}.requiredCoverage`); + const covered = strings(host.coveredCoverage, `${role}.coveredCoverage`); + waiver(root, host.waiverAdr, `${role}.waiverAdr`); + if (!Array.isArray(host.artifacts)) fail(`${role}.artifacts must be an array`); + host.artifacts.forEach((entry, index) => artifact(root, entry, `${role}.artifacts[${index}]`)); + if (host.status === "passed") { + if (host.automation !== "passed") fail(`${role} passed without automation`); + if (typeof host.browserVersion !== "string" || !host.browserVersion) { + fail(`${role} passed without a browser version`); + } + if (host.observedAt === null || host.expiresAt === null) fail(`${role} passed without a validity window`); + if (!validWindow(host.observedAt, host.expiresAt, role, now)) fail(`${role} passed on expired evidence`); + if (!completeCoverage(required, covered)) fail(`${role} passed with incomplete coverage`); + if (host.waiverAdr !== null) fail(`${role} passed through a waiver`); + if (host.artifacts.length === 0) fail(`${role} passed without hashed artifacts`); + } else if (host.status === "not-configured") { + if ( + host.automation !== "not-configured" || + host.browserVersion !== null || + host.observedAt !== null || + host.expiresAt !== null || + covered.length !== 0 || + host.artifacts.length !== 0 || + host.waiverAdr !== null + ) { + fail(`${role} claims evidence while not configured`); + } + } + } + const missing = requiredHosts.filter( + (id) => value.hosts.find((host) => host.id === id)?.status !== "passed", + ); + if (JSON.stringify(value.missingRequiredHosts) !== JSON.stringify(missing)) { + fail("browser missing-host projection drifted"); + } + const result = missing.length === 0 ? "ready" : "blocked"; + if (value.result !== result) fail("browser result does not match required hosts"); + if (typeof value.releaseBoundary !== "string" || !value.releaseBoundary) { + fail("browser release boundary is missing"); + } + return { + schemaVersion: value.schemaVersion, + sourceCommit: rootSource.commit, + gitTree: rootSource.gitTree, + result, + passed: value.hosts.filter((host) => host.status === "passed").map((host) => host.id), + missingRequiredHosts: missing, + }; +} diff --git a/scripts/release-authority.test.mjs b/scripts/release-authority.test.mjs new file mode 100644 index 0000000..e15c916 --- /dev/null +++ b/scripts/release-authority.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + AuthorityError, + validateBrowserMatrix, + validateReleaseReadiness, +} from "./release-authority.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const fixedNow = Date.parse("2026-07-22T15:00:00Z"); +const readiness = JSON.parse( + readFileSync(resolve(root, "docs/product/release-readiness-0.1.0.json"), "utf8"), +); +const browser = JSON.parse( + readFileSync(resolve(root, "docs/benchmarks/hosted-browser-matrix.json"), "utf8"), +); +assert.equal( + validateReleaseReadiness(readiness, { root, now: fixedNow, verifyGit: false }).result, + "blocked", +); +assert.equal( + validateBrowserMatrix(browser, { root, now: fixedNow, verifyGit: false }).result, + "blocked", +); + +const unbound = structuredClone(readiness); +unbound.checks[0].source.commit = "0".repeat(40); +assert.throws( + () => validateReleaseReadiness(unbound, { root, now: fixedNow, verifyGit: false }), + (error) => error instanceof AuthorityError && /not bound/u.test(error.message), +); + +const artifactless = structuredClone(readiness); +artifactless.checks[0].status = "passed"; +artifactless.checks[0].evidenceClass = "measured"; +artifactless.checks[0].artifacts = []; +assert.throws( + () => validateReleaseReadiness(artifactless, { root, now: fixedNow, verifyGit: false }), + (error) => error instanceof AuthorityError && /hashed artifact/u.test(error.message), +); + +const externalWaiver = structuredClone(readiness); +externalWaiver.checks[2].waiverAdr = "docs/index.md"; +assert.throws( + () => validateReleaseReadiness(externalWaiver, { root, now: fixedNow, verifyGit: false }), + (error) => error instanceof AuthorityError && /inside docs\/adr/u.test(error.message), +); + +const optimisticBrowser = structuredClone(browser); +Object.assign(optimisticBrowser.hosts[0], { + status: "passed", + automation: "passed", + browserVersion: "150.0.0", + observedAt: "2026-07-22T14:10:00Z", + expiresAt: "2026-07-29T14:10:00Z", + coveredCoverage: optimisticBrowser.hosts[0].requiredCoverage, +}); +assert.throws( + () => validateBrowserMatrix(optimisticBrowser, { root, now: fixedNow, verifyGit: false }), + (error) => error instanceof AuthorityError && /hashed artifacts/u.test(error.message), +); + +for (const script of ["check-release-readiness.mjs", "check-hosted-browser-matrix.mjs"]) { + const result = spawnSync(process.execPath, [resolve(root, "scripts", script), "--profile=release"], { + cwd: root, + encoding: "utf8", + windowsHide: true, + }); + assert.equal(result.status, 1, `${script} must block the release profile`); + assert.match(result.stderr, /blocked/u); +} + +process.stdout.write("release authority fail-closed contracts: pass\n"); diff --git a/scripts/render-benchmark-authority-v2.mjs b/scripts/render-benchmark-authority-v2.mjs new file mode 100644 index 0000000..a7a8e1b --- /dev/null +++ b/scripts/render-benchmark-authority-v2.mjs @@ -0,0 +1,114 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + corpusMetrics, + loadAuthority, + materializeCorpus, + repositoryRoot, +} from "./benchmark-authority-v2.mjs"; + +const arguments_ = process.argv.slice(2); +if (arguments_.length !== 1 || !new Set(["--check", "--write"]).has(arguments_[0])) { + throw new Error("usage: node scripts/render-benchmark-authority-v2.mjs --check|--write"); +} + +const authority = loadAuthority(); +const destination = resolve(repositoryRoot, authority.artifacts.generatedDocumentation); +const tag = (lane) => lane.distTag ?? "pinned version"; +const laneRows = authority.lanes + .map( + (lane) => + `| \`${lane.id}\` | ${lane.role} | \`${tag(lane)}\` | \`${lane.version}\` | \`${lane.cliVersion}\` |`, + ) + .join("\n"); +const corpusRows = authority.corpora + .map((corpus) => { + const metrics = corpusMetrics(materializeCorpus(corpus)); + return `| \`${corpus.id}\` | ${corpus.scope} | ${metrics.htmlBytes.toLocaleString("en-US")} | ${metrics.classAttributeCount.toLocaleString("en-US")} | ${metrics.utilityOccurrences.toLocaleString("en-US")} | ${metrics.uniqueUtilityTokens.toLocaleString("en-US")} |`; + }) + .join("\n"); + +const markdown = `# Tailwind Benchmark Authority v2 + +Status: **active machine authority; generated from [\`oracle.json\`](../../benchmarks/benchmark-authority-v2/oracle.json)** + +This document is generated. Edit the schema-2 JSON authority and run +\`node scripts/render-benchmark-authority-v2.mjs --write\`; do not maintain the tables by hand. + +The primary competitor is \`${authority.primaryLane}\`. The oracle was observed at +\`${authority.observedAtUtc}\`, expires at \`${authority.expiresAtUtc}\`, and has a maximum age of +${authority.maximumAgeHours} hours. An expired or live-registry-mismatched oracle blocks a new +competitive result. The reset contract is explicitly **${authority.resetContract}** because +PliegoCSS does not emit Preflight. + +## Competitor lanes + +| Lane | Role | Registry selector | Tailwind | CLI | +|---|---|---|---:|---:| +${laneRows} + +\`tailwind-frozen-release\` is a regression control. It is not allowed to stand in for current +Tailwind. The v3 lane follows the upstream npm \`v3-lts\` dist-tag; “LTS” here names that registry +lane and does not expand Tailwind's support promises. + +## Corpora + +| Corpus | Scope | HTML bytes | Class attributes | Utility occurrences | Unique utilities | +|---|---|---:|---:|---:|---:| +${corpusRows} + +The large corpus repeats the medium body ${authority.corpora.find((corpus) => corpus.id === "large").repeat} +times. It measures source-volume scaling while deliberately keeping the same utility vocabulary; +it is not presented as broader catalog coverage. + +## Measurement contract + +- ${authority.methodology.warmupPairs} warmup pairs and ${authority.methodology.measuredPairs} measured pairs per lane/corpus. +- Pair order alternates: ${authority.methodology.pairOrder}. +- Every observation is a fresh process. Complete per-pair latency and peak-working-set samples are retained. +- A separate fresh invocation must reproduce each measured output hash. +- CSS and rewritten/source HTML report raw, gzip level ${authority.methodology.compression.gzipLevel}, and Brotli quality ${authority.methodology.compression.brotliQuality} bytes. +- Transfer compares separate HTML and CSS streams under both compression formats. +- Candidate coverage must be 100% for the shared corpus before a pair is comparable. +- PliegoCSS and Tailwind use the same no-Preflight utility intent; browser computed-style equivalence belongs to G4. + +Peak working set is measured for the direct child process by +[\`benchmark-process-metrics.rs\`](../../scripts/benchmark-process-metrics.rs), not inferred from +host free-memory deltas. Process startup, security tooling and normal OS caches remain part of the +fresh-process observation; the release build happens outside the timed region. + +## Commands + +\`\`\`console +pnpm check:benchmark-authority +pnpm check:benchmark-oracle +node scripts/measure-benchmark-authority-v2.mjs --smoke +node scripts/measure-benchmark-authority-v2.mjs +\`\`\` + +Immutable evidence additionally uses \`--evidence=benchmarks/evidence/v2/.json\`, the +canonical 5/30 sample counts, a clean source tree, a new destination, and an explicit security-tooling +declaration. Machine-local results remain ignored at +\`${authority.artifacts.localResult}\`. + +## Claim boundary + +${authority.methodology.scorePolicy} + +Legacy Gate A/B snapshots remain historical integrity evidence. They cannot supply the current +competitor lane, memory, Brotli, or micro/medium/large claims and therefore cannot produce the v2 +competitive score. +`; + +if (arguments_[0] === "--write") { + writeFileSync(destination, markdown); + process.stdout.write(`${destination}\n`); +} else { + const current = readFileSync(destination, "utf8"); + if (current !== markdown) { + throw new Error(`${authority.artifacts.generatedDocumentation} is stale; run the renderer with --write`); + } + process.stdout.write( + `${JSON.stringify({ schemaVersion: 2, status: "passed", document: authority.artifacts.generatedDocumentation, lanes: authority.lanes.length, corpora: authority.corpora.length }, null, 2)}\n`, + ); +} diff --git a/scripts/render-browser-output-certification.mjs b/scripts/render-browser-output-certification.mjs new file mode 100644 index 0000000..4b3e010 --- /dev/null +++ b/scripts/render-browser-output-certification.mjs @@ -0,0 +1,113 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { repositoryRoot } from "./benchmark-authority-v2.mjs"; + +const authority = JSON.parse( + readFileSync( + resolve(repositoryRoot, "benchmarks", "browser-output-certification-v1", "authority.json"), + "utf8", + ), +); +const outputPath = resolve( + repositoryRoot, + "docs", + "benchmarks", + "browser-output-certification-v1.md", +); + +function tableRows() { + return authority.hosts + .map( + (host) => + `| \`${host.id}\` | \`${host.runner}\` | ${host.os} | ${host.arch} | ${host.browser} |`, + ) + .join("\n"); +} + +const document = `# Browser/output certification v1 + +Status: **implemented hosted 3×3 workflow; release evidence remains source-bound** + +This page is generated from +[\`authority.json\`](../../benchmarks/browser-output-certification-v1/authority.json). Edit the +JSON contract and run \`node scripts/render-browser-output-certification.mjs --write\`; do not +maintain the matrix by hand. + +## Scope + +The certification compiles the frozen medium fixture once with PliegoCSS and once with the fresh +\`${authority.competitorLane}\` lane from Benchmark Authority v2. Within each host it compares +${authority.computedProperties.length} computed properties on every styled node and captures both +original screenshots plus a pixel-diff image for every scenario. + +Passing proves only the frozen shared utility intent. It is not general Tailwind compatibility, +does not compare pixels across different browser engines, and does not broaden PliegoCSS's utility +catalog. + +## Reset contract + +| Mode | Contract | +|---|---| +${authority.resetModes.map((mode) => `| \`${mode.id}\` | ${mode.contract} |`).join("\n")} + +The shared reset is isolated in \`@layer reset\`. Tailwind Preflight is not silently enabled because +PliegoCSS does not own a Preflight implementation. This keeps the reset/no-reset comparison +explicit and prevents an unlayered reset from outranking Tailwind's layered utilities. + +## Hosted matrix + +| Host | GitHub runner | OS | Architecture | Browser engine | +|---|---|---|---|---| +${tableRows()} + +The matrix is the full browser/OS cross-product for Chromium, Firefox, and WebKit across Windows, +Linux, and macOS. Windows and Linux provide x64 evidence; the standard \`macos-15\` runner provides +ARM64 evidence. + +## Scenarios + +| Scenario | Viewport | State | +|---|---:|---| +${authority.scenarios.map((scenario) => `| \`${scenario.id}\` | ${scenario.width}×${scenario.height} | ${scenario.action} |`).join("\n")} + +Every scenario runs under both reset modes. Computed colors normalize to 8-bit sRGB, equivalent +flex-end keywords normalize together, and layout lengths quantize to +${authority.comparison.computedLengthQuantumCssPx} CSS px to account for browser-internal +subpixel serialization. The same host must also match every styled border box, client/scroll extent, +and direct text-node rectangle captured at ${authority.comparison.layoutGeometryResolutionCssPx} CSS +px resolution with at most ${authority.comparison.maximumLayoutGeometryDeltaCssPx} CSS px delta. +Each scenario and host summary retain the maximum observed geometry delta; aggregation recomputes +that maximum and rejects missing, non-finite, negative, over-budget, or summary-drifted values. +Screenshots use pixelmatch threshold +${authority.comparison.screenshotPixelThreshold} and may differ on at most +${authority.comparison.maximumScreenshotMismatchRatio * 100}% of CSS pixels; original PNG hashes +remain retained so the tolerance cannot hide or rewrite evidence. + +## Commands + +\`\`\`console +pnpm check:browser-output-authority +pnpm exec playwright install chromium firefox webkit +pnpm check:browser-output -- --browser=chromium +pnpm check:browser-output -- --browser=firefox +pnpm check:browser-output -- --browser=webkit +\`\`\` + +CI executes each of the nine hosts with \`--require-clean\`, uploads the JSON and PNG artifacts, +then aggregates them only when every host is unexpired, clean-tree, bound to one commit/tree, and +complete. That source identity must equal the aggregator checkout, so nine mutually consistent but +stale documents cannot pass. Local dirty-tree runs are diagnostic and cannot satisfy the hosted matrix. +`; + +const mode = process.argv[2]; +if (mode === "--write") { + writeFileSync(outputPath, document); + process.stdout.write(`wrote ${outputPath}\n`); +} else if (mode === "--check") { + if (readFileSync(outputPath, "utf8") !== document) { + throw new Error("browser/output certification documentation is stale; run --write"); + } + process.stdout.write("browser/output certification documentation: current\n"); +} else { + throw new Error("usage: node scripts/render-browser-output-certification.mjs --write|--check"); +} diff --git a/scripts/render-external-adoption.mjs b/scripts/render-external-adoption.mjs new file mode 100644 index 0000000..5e2bf16 --- /dev/null +++ b/scripts/render-external-adoption.mjs @@ -0,0 +1,135 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + loadExternalAdoptionBundle, + validateExternalAdoptionBundle, +} from "./external-adoption-v1.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputPath = resolve(root, "docs", "product", "external-adoption-g7.md"); +const siteOutputPath = resolve(root, "docs", "site", "external-adoption.md"); + +export function renderExternalAdoption(status) { + const missing = status.missingCoverage.map((item) => `\`${item}\``).join(", "); + return [ + "# G7 external adoption evidence", + "", + "", + "", + `Status: **${status.result}**`, + "", + "G7 is an external-evidence gate, not a fixture-writing milestone. Its machine authority is", + "[`benchmarks/external-adoption-v1/authority.json`](../../benchmarks/external-adoption-v1/authority.json).", + "The public ledgers contain only consented, redacted, reviewed records; raw material remains in", + "private custody and is represented by a stable SHA-256 digest.", + "", + "## Recruitment", + "", + `Recruitment is **${status.recruitment.status}**. The public cohort notice is`, + `[GitHub issue #7](${status.recruitment.publicCohortUrl}). Apply privately through`, + `[hello@pliegocss.dev](${status.recruitment.privateContact}) after reading the`, + `[privacy notice](${status.recruitment.privacyNotice}). Public posts are recruitment signals`, + "only and never count as consent or evidence.", + "", + "## Current qualified evidence", + "", + "| Evidence | Qualified | Required |", + "| --- | ---: | ---: |", + `| External interviews | ${status.counts.qualifiedInterviews} | ${status.thresholds.interviews.minimumQualified}–${status.thresholds.interviews.targetMaximum} |`, + `| Real incidents | ${status.counts.qualifiedIncidents} | ${status.thresholds.incidents.minimumQualified} |`, + `| Completed pilots | ${status.counts.qualifiedPilots} | ${status.thresholds.pilots.minimumQualified}–${status.thresholds.pilots.targetMaximum} |`, + `| Independent projects | ${status.counts.independentProjects} | ${status.thresholds.pilots.minimumIndependentProjects} |`, + `| Independent organizations | ${status.counts.independentOrganizations} | ${status.thresholds.pilots.minimumIndependentOrganizations} |`, + "", + `The adapter policy is **${status.policy.status}** for \`${status.policy.releaseLine}\`.`, + `Missing promotion coverage: ${missing || "none"}.`, + "", + "## Admission boundary", + "", + "- Interviews count once per pseudonymous participant and require recorded research/public-summary consent.", + "- Incidents must be observed in an external project. Synthetic conformance cases never count.", + "- Pilots must complete adoption, browser equivalence, and exact rollback with zero blocking failures.", + "- Pilot projects and organizations must be unique and outside Celiums/PliegoCSS ownership.", + "- Every record requires an approved review, a redacted public entry, and a digest for its private source.", + "- Draft notes, personal data, raw transcripts, access tokens, and proprietary source are never committed.", + "", + "The exact intake and custody workflow is documented in", + "[`external-adoption-intake.md`](../contributing/external-adoption-intake.md). The promoted adapter", + "scope is frozen in [`adapter-support-policy-0.1.x.json`](./adapter-support-policy-0.1.x.json).", + "", + "## Commands", + "", + "```console", + "pnpm check:external-adoption-authority", + "pnpm check:external-adoption", + "```", + "", + "The first command validates an honest blocked or ready state. The second is a promotion gate and", + "fails until every required coverage item is present.", + "", + "## Claim boundary", + "", + status.claimBoundary, + "", + ].join("\n"); +} + +export function renderSiteExternalAdoption(status) { + return [ + "", + "", + "# External adoption is a measured gate.", + "", + `G7 is currently ${status.result}. Owned fixtures and synthetic incidents cannot satisfy it.`, + "", + "## Qualified evidence {#status}", + "", + `Interviews: ${status.counts.qualifiedInterviews}/${status.thresholds.interviews.minimumQualified}. Real incidents: ${status.counts.qualifiedIncidents}/${status.thresholds.incidents.minimumQualified}. Completed pilots: ${status.counts.qualifiedPilots}/${status.thresholds.pilots.minimumQualified}. Independent projects: ${status.counts.independentProjects}/${status.thresholds.pilots.minimumIndependentProjects}. Independent organizations: ${status.counts.independentOrganizations}/${status.thresholds.pilots.minimumIndependentOrganizations}.`, + "", + "## Applications are open {#apply}", + "", + `Read the [public cohort notice](${status.recruitment.publicCohortUrl}) or apply privately at [hello@pliegocss.dev](${status.recruitment.privateContact}). Do not post names, email addresses, credentials, private repository URLs, proprietary source, transcripts, or incident material on GitHub. A public application is not consent and is not evidence.`, + "", + "## Admission boundary {#boundary}", + "", + "Only consented, redacted, reviewed external records bound to private source evidence by SHA-256 count. Projects owned by Celiums Solutions, PliegoCSS, or celiumsai do not count.", + "", + "## Certified adapter scope {#support}", + "", + "The frozen 0.1.x policy covers complete literal class groups in static HTML, Vite 8.1.5, and pinned PliegoRS rendered output against Tailwind CSS 3.4.19 or 4.3.3. Tailwind stays loaded during coexistence. Dynamic classes, arbitrary config/plugin execution, and Tailwind removal are excluded.", + "", + ].join("\n"); +} + +const args = process.argv.slice(2); +if (args.length > 1 || (args[0] && !["--check", "--write"].includes(args[0]))) { + throw new Error("usage: node scripts/render-external-adoption.mjs [--check|--write]"); +} +const mode = args[0] ?? "--check"; +const status = validateExternalAdoptionBundle(loadExternalAdoptionBundle(root), { root }); +const rendered = renderExternalAdoption(status); +const siteRendered = renderSiteExternalAdoption(status); +if (mode === "--write") { + writeFileSync(outputPath, rendered, "utf8"); + writeFileSync(siteOutputPath, siteRendered, "utf8"); + process.stdout.write(`wrote ${outputPath}\nwrote ${siteOutputPath}\n`); +} else { + const current = readFileSync(outputPath, "utf8").replaceAll("\r\n", "\n"); + if (current !== rendered) { + throw new Error("external adoption Markdown drifted; run with --write"); + } + const siteCurrent = readFileSync(siteOutputPath, "utf8").replaceAll("\r\n", "\n"); + if (siteCurrent !== siteRendered) { + throw new Error("site external adoption Markdown drifted; run with --write"); + } + process.stdout.write("external adoption Markdown projections: current\n"); +} diff --git a/scripts/render-release-readiness.mjs b/scripts/render-release-readiness.mjs new file mode 100644 index 0000000..2cc3f38 --- /dev/null +++ b/scripts/render-release-readiness.mjs @@ -0,0 +1,167 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { validateReleaseReadiness } from "./release-authority.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const sourcePath = resolve(root, "docs/product/release-readiness-0.1.0.json"); +const outputPath = resolve(root, "docs/product/release-readiness-0.1.0.md"); +const siteOutputPath = resolve(root, "docs/site/release-readiness.md"); + +function cell(value) { + return String(value).replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function linkArtifact(artifact) { + return `[${artifact.path}](${artifact.url}) — \`${artifact.sha256}\``; +} + +export function renderReleaseReadiness(value) { + const summary = validateReleaseReadiness(value, { root }); + const lines = [ + "# PliegoCSS 0.1.0 release readiness", + "", + "", + "", + `Status: **${summary.result}**`, + "", + "This page is a deterministic projection of the schema-3", + "[`release-readiness-0.1.0.json`](./release-readiness-0.1.0.json) machine authority.", + "Historical reports and release notes do not change this state.", + "", + "## Candidate identity", + "", + "| Field | Value |", + "| --- | --- |", + `| Target | \`${cell(value.targetVersion)}\` |`, + `| Candidate | \`${cell(value.candidateVersion)}\` |`, + `| Tag | \`${cell(value.source.tag)}\` |`, + `| Source commit | \`${cell(value.source.commit)}\` |`, + `| Git tree | \`${cell(value.source.gitTree)}\` |`, + `| Observed | \`${cell(value.observedAt)}\` |`, + `| Expires | \`${cell(value.expiresAt)}\` |`, + "", + "## Readiness dimensions", + "", + "| Dimension | Ready |", + "| --- | --- |", + `| Technical | ${value.dimensions.technicalReady ? "yes" : "no"} |`, + `| Operational | ${value.dimensions.operationallyReady ? "yes" : "no"} |`, + `| Adoption | ${value.dimensions.adoptionReady ? "yes" : "no"} |`, + `| Authorized | ${value.dimensions.authorized ? "yes" : "no"} |`, + `| Promotion | ${value.dimensions.promotionReady ? "yes" : "no"} |`, + "", + "## Required checks", + "", + "| Check | Dimension | Status | Evidence | Coverage | Waiver |", + "| --- | --- | --- | --- | ---: | --- |", + ]; + for (const check of value.checks) { + lines.push( + `| \`${cell(check.id)}\` | ${cell(check.dimension)} | **${cell(check.status)}** | ${cell(check.evidenceClass)} | ${check.coveredCoverage.length}/${check.requiredCoverage.length} | ${check.waiverAdr ? `\`${cell(check.waiverAdr)}\`` : "none"} |`, + ); + } + lines.push("", "## Evidence and blockers", ""); + for (const check of value.checks) { + lines.push( + `### ${check.id}`, + "", + check.summary, + "", + `- Source: \`${check.source.commit}\` / tree \`${check.source.gitTree}\``, + `- Validity: \`${check.observedAt}\` → \`${check.expiresAt}\``, + `- Required coverage: ${check.requiredCoverage.map((item) => `\`${item}\``).join(", ")}`, + `- Covered: ${check.coveredCoverage.length === 0 ? "none" : check.coveredCoverage.map((item) => `\`${item}\``).join(", ")}`, + ); + if (check.artifacts.length > 0) { + lines.push("- Hashed artifacts:"); + for (const artifact of check.artifacts) lines.push(` - ${linkArtifact(artifact)}`); + } else { + lines.push("- Hashed artifacts: none"); + } + if (check.references.length > 0) { + lines.push("- References:"); + for (const reference of check.references) { + lines.push(` - [${reference.kind}](${reference.url})`); + } + } + lines.push(""); + } + lines.push( + "## Promotion rule", + "", + value.promotionRule, + "", + "Archived snapshots are indexed under [`../archive/`](../archive/README.md).", + "", + ); + return lines.join("\n"); +} + +export function renderSiteReleaseReadiness(value) { + const summary = validateReleaseReadiness(value, { root }); + const dimensions = [ + ["Technical", value.dimensions.technicalReady], + ["Operational", value.dimensions.operationallyReady], + ["Adoption", value.dimensions.adoptionReady], + ["Authorized", value.dimensions.authorized], + ["Promotion", value.dimensions.promotionReady], + ] + .map(([label, ready]) => `${label}: ${ready ? "ready" : "blocked"}`) + .join("; "); + const blockers = value.checks + .filter((check) => check.status !== "passed") + .map((check) => `${check.id} (${check.status}): ${check.summary}`) + .join(" "); + return [ + "", + "", + "# Release truth bound to one exact candidate.", + "", + `The current 1.0 promotion state is ${summary.result}; no historical report can override these source-bound gates.`, + "", + "## Candidate identity {#candidate}", + "", + `Target ${value.targetVersion}; candidate ${value.candidateVersion}; tag ${value.source.tag}; commit ${value.source.commit}; Git tree ${value.source.gitTree}; observed ${value.observedAt}; expires ${value.expiresAt}.`, + "", + "## Readiness dimensions {#dimensions}", + "", + `${dimensions}.`, + "", + "## Current blockers {#blockers}", + "", + blockers || "No blockers remain in the machine authority.", + "", + ].join("\n"); +} + +const args = process.argv.slice(2); +if (args.length > 1 || (args[0] && !["--check", "--write"].includes(args[0]))) { + throw new Error(`usage: node scripts/render-release-readiness.mjs [--check|--write]`); +} +const mode = args[0] ?? "--check"; +const document = JSON.parse(readFileSync(sourcePath, "utf8")); +const rendered = renderReleaseReadiness(document); +const siteRendered = renderSiteReleaseReadiness(document); +if (mode === "--write") { + writeFileSync(outputPath, rendered, "utf8"); + writeFileSync(siteOutputPath, siteRendered, "utf8"); + process.stdout.write(`wrote ${outputPath}\nwrote ${siteOutputPath}\n`); +} else { + const current = readFileSync(outputPath, "utf8").replaceAll("\r\n", "\n"); + if (current !== rendered) { + throw new Error("release readiness Markdown drifted; run with --write"); + } + const siteCurrent = readFileSync(siteOutputPath, "utf8").replaceAll("\r\n", "\n"); + if (siteCurrent !== siteRendered) { + throw new Error("site release readiness Markdown drifted; run with --write"); + } + process.stdout.write("release readiness Markdown projections: current\n"); +} diff --git a/scripts/render-tailwind-matrix.mjs b/scripts/render-tailwind-matrix.mjs new file mode 100644 index 0000000..0914f7c --- /dev/null +++ b/scripts/render-tailwind-matrix.mjs @@ -0,0 +1,113 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadAuthority, repositoryRoot } from "./benchmark-authority-v2.mjs"; + +const matrixPath = resolve(repositoryRoot, "docs/benchmarks/tailwind-v4-competitive-matrix.json"); +const documentPath = resolve(repositoryRoot, "docs/benchmarks/tailwind-v4-competitive-matrix.md"); + +function cell(value) { + return `${value}`.replaceAll("|", "\\|").replaceAll(/\s+/gu, " ").trim(); +} + +export function renderTailwindMatrix() { + const matrix = JSON.parse(readFileSync(matrixPath, "utf8")); + const authority = loadAuthority(); + const lanes = authority.lanes + .map( + (lane) => + `| \`${lane.id}\` | ${cell(lane.role)} | \`${lane.distTag ?? "pinned"}\` | \`${lane.version}\` |`, + ) + .join("\n"); + const dimensions = matrix.dimensions + .map( + (dimension) => + `| \`${dimension.id}\` | ${cell(dimension.area)} | **${cell(dimension.status)}** | ${cell(dimension.tailwind)} | ${cell(dimension.pliego)} | ${cell(dimension.releaseImpact)} |`, + ) + .join("\n"); + const priorities = (impact) => + matrix.dimensions + .filter((dimension) => dimension.releaseImpact === impact) + .map((dimension) => `- \`${dimension.id}\`: ${dimension.nextAction}`) + .join("\n") || "- None."; + const counts = Object.fromEntries(Object.keys(matrix.statuses).map((status) => [status, 0])); + for (const dimension of matrix.dimensions) counts[dimension.status] += 1; + const countText = Object.entries(counts) + .map(([status, count]) => `\`${status}\` ${count}`) + .join(", "); + + return `# PliegoCSS and Tailwind CSS ${matrix.baseline.version} + +Status: **generated competitive matrix for release planning; not a universal performance or parity claim** + +This document is generated from [\`tailwind-v4-competitive-matrix.json\`](./tailwind-v4-competitive-matrix.json). +The competitor and corpus authority is [Benchmark Authority v2](./tailwind-benchmark-authority-v2.md). +Edit those JSON contracts and regenerate; do not update this table by hand. + +PliegoCSS is not positioned as “Tailwind in Rust.” Tailwind is the production utility-framework +baseline; PliegoCSS competes through typed semantics, policy, provenance, ownership, diagnostics and +receipts. Catalog breadth and a historical single-fixture timing are not a complete score. + +## Active oracle + +Primary lane: \`${authority.primaryLane}\`. Oracle observation: \`${authority.observedAtUtc}\`. +Expiration: \`${authority.expiresAtUtc}\`. Reset contract: **${authority.resetContract}**. + +| Lane | Role | Registry selector | Version | +|---|---|---|---:| +${lanes} + +The \`4.3.2\` lane is historical regression control only. Current performance and payload claims +remain open until immutable schema-2 evidence is recorded from a clean tree with complete paired +samples, peak memory, gzip, Brotli and all three corpus sizes. + +## Executive matrix + +| Dimension | Area | Assessment | Tailwind | PliegoCSS | Release impact | +|---|---|---|---|---|---| +${dimensions} + +Status totals: ${countText}. + +## Blocking + +${priorities("blocking")} + +## Should + +${priorities("should")} + +## Explicitly post-0.1 + +${priorities("post-0.1")} + +## Historical evidence boundary + +[Gate B](./pliego-gate-b.md) remains immutable historical evidence for Tailwind \`4.3.2\` on its +recorded host. It cannot represent \`tailwind-latest\`, v3-LTS, current paired latency, memory, +Brotli, or micro/medium/large behavior. No current competitive score is emitted from that snapshot. + +## Verification + +\`\`\`console +pnpm check:benchmark-authority +pnpm check:benchmark-oracle +pnpm check:tailwind-matrix +node scripts/measure-benchmark-authority-v2.mjs --smoke +\`\`\` +`; +} + +const arguments_ = process.argv.slice(2); +if (arguments_.length !== 1 || !new Set(["--check", "--write"]).has(arguments_[0])) { + throw new Error("usage: node scripts/render-tailwind-matrix.mjs --check|--write"); +} +const rendered = renderTailwindMatrix(); +if (arguments_[0] === "--write") { + writeFileSync(documentPath, rendered); + process.stdout.write(`${documentPath}\n`); +} else { + if (readFileSync(documentPath, "utf8") !== rendered) { + throw new Error("Tailwind competitive matrix Markdown is stale; run the renderer with --write"); + } + process.stdout.write(`${JSON.stringify({ schemaVersion: 2, status: "passed", document: "docs/benchmarks/tailwind-v4-competitive-matrix.md" }, null, 2)}\n`); +} diff --git a/scripts/repository-distribution.mjs b/scripts/repository-distribution.mjs new file mode 100644 index 0000000..927fe73 --- /dev/null +++ b/scripts/repository-distribution.mjs @@ -0,0 +1,339 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { basename, isAbsolute, relative, resolve } from "node:path"; + +export const ROOT = resolve(import.meta.dirname, ".."); +export const POLICY_PATH = resolve( + ROOT, + "distribution", + "repository-distribution-v1.json", +); +export const PACKAGE_PATH = resolve(ROOT, "packages", "cli", "package.json"); + +const COMMIT = /^[0-9a-f]{40}$/u; +const DIGEST = /^[0-9a-f]{64}$/u; +const RELEASE_TAG = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; + +export function fail(message) { + throw new Error(`repository distribution: ${message}`); +} + +export function readJson(path, role) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`${role} is not valid JSON: ${error.message}`); + } +} + +export function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function fileSha256(path) { + return sha256(readFileSync(path)); +} + +export function exactKeys(value, expected, role) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${role} must be an object`); + } + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + fail(`${role} fields drifted: ${actual.join(", ")}`); + } +} + +export function repositoryVersion() { + const manifest = readFileSync(resolve(ROOT, "Cargo.toml"), "utf8"); + const match = /^version = "([^"]+)"$/mu.exec(manifest); + if (!match) fail("workspace package version is missing"); + return match[1]; +} + +export function validateSource(source, { allowNullTag = true } = {}) { + exactKeys(source, ["commit", "gitTree", "tag"], "source"); + if (!COMMIT.test(source.commit) || !COMMIT.test(source.gitTree)) { + fail("source commit and Git tree must be full lowercase object IDs"); + } + if ( + source.tag !== null && + (typeof source.tag !== "string" || !RELEASE_TAG.test(source.tag)) + ) { + fail("source tag must be a canonical release tag or null"); + } + if (!allowNullTag && source.tag === null) fail("release source tag is required"); +} + +export function validatePolicy(policy = readJson(POLICY_PATH, "policy")) { + exactKeys( + policy, + [ + "schemaVersion", + "kind", + "repository", + "releaseAssetBase", + "package", + "release", + "binaries", + "targets", + "claimBoundary", + ], + "policy", + ); + if ( + policy.schemaVersion !== 1 || + policy.kind !== "pliegocss-repository-distribution-policy" || + policy.repository !== "https://github.com/celiumsai/pliegocss" || + policy.releaseAssetBase !== + "https://github.com/celiumsai/pliegocss/releases/download" + ) { + fail("policy identity drifted"); + } + exactKeys( + policy.package, + [ + "name", + "format", + "registryPublication", + "distributionChannel", + "packageManager", + "packageManagerVersion", + "lifecycleScripts", + "dependencies", + ], + "policy.package", + ); + if ( + policy.package.name !== "@pliegocss/cli" || + policy.package.format !== "npm-tarball" || + policy.package.registryPublication !== "forbidden" || + policy.package.distributionChannel !== "github-release-asset" || + policy.package.packageManager !== "pnpm" || + policy.package.packageManagerVersion !== "11.7.0" || + policy.package.lifecycleScripts !== "forbidden" || + policy.package.dependencies !== "forbidden" + ) { + fail("repository-only pnpm package policy drifted"); + } + exactKeys( + policy.release, + [ + "draftFirst", + "immutableRequiredBeforePublication", + "checksums", + "sbom", + "sbomTool", + "sbomToolVersion", + "provenance", + ], + "policy.release", + ); + if ( + policy.release.draftFirst !== true || + policy.release.immutableRequiredBeforePublication !== true || + policy.release.checksums !== "sha256" || + policy.release.sbom !== "cyclonedx-json-1.5" || + policy.release.sbomTool !== "cargo-cyclonedx" || + policy.release.sbomToolVersion !== "0.5.9" || + policy.release.provenance !== "github-sigstore-attestation" + ) { + fail("release security policy drifted"); + } + if ( + JSON.stringify(policy.binaries) !== + JSON.stringify(["pliego-cssc", "pliego-css-lsp"]) + ) { + fail("binary boundary drifted"); + } + if (!Array.isArray(policy.targets) || policy.targets.length !== 3) { + fail("exactly three native targets are required"); + } + const expectedTargets = [ + { + id: "x86_64-pc-windows-msvc", + os: "win32", + arch: "x64", + runner: "windows-latest", + extension: ".exe", + format: "pe", + }, + { + id: "x86_64-unknown-linux-gnu", + os: "linux", + arch: "x64", + runner: "ubuntu-24.04", + extension: "", + format: "elf", + }, + { + id: "aarch64-apple-darwin", + os: "darwin", + arch: "arm64", + runner: "macos-15", + extension: "", + format: "mach-o", + }, + ]; + const targetIds = new Set(); + const hostPairs = new Set(); + for (const target of policy.targets) { + exactKeys( + target, + ["id", "os", "arch", "runner", "extension", "format"], + `policy.targets.${target.id ?? "unknown"}`, + ); + if (!/^[A-Za-z0-9_-]+$/u.test(target.id ?? "") || targetIds.has(target.id)) { + fail(`target ID is invalid or duplicated: ${target.id}`); + } + targetIds.add(target.id); + const pair = `${target.os}:${target.arch}`; + if (hostPairs.has(pair)) fail(`host pair is duplicated: ${pair}`); + hostPairs.add(pair); + if (!new Set(["pe", "elf", "mach-o"]).has(target.format)) { + fail(`unsupported binary format for ${target.id}`); + } + } + if (JSON.stringify(policy.targets) !== JSON.stringify(expectedTargets)) { + fail("native target matrix drifted"); + } + if (typeof policy.claimBoundary !== "string" || !policy.claimBoundary) { + fail("claim boundary is missing"); + } + return policy; +} + +export function validatePackageManifest( + manifest = readJson(PACKAGE_PATH, "pnpm package"), + policy = validatePolicy(), + { packed = false } = {}, +) { + const forbiddenLifecycle = new Set([ + "preinstall", + "install", + "postinstall", + "prepare", + "prepublish", + "prepublishOnly", + ]); + if ( + manifest.name !== policy.package.name || + manifest.version !== repositoryVersion() || + manifest.private !== true || + (!packed && manifest.packageManager !== `pnpm@${policy.package.packageManagerVersion}`) + ) { + fail("pnpm package identity, version, or private boundary drifted"); + } + if ( + manifest.engines?.pnpm !== ">=11.7.0 <12" || + (packed && + Object.hasOwn(manifest, "packageManager") && + manifest.packageManager !== `pnpm@${policy.package.packageManagerVersion}`) + ) { + fail("pnpm package manager recommendation drifted"); + } + for (const dependencyField of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) { + if ( + Object.hasOwn(manifest, dependencyField) && + Object.keys(manifest[dependencyField] ?? {}).length > 0 + ) { + fail(`pnpm package must not contain ${dependencyField}`); + } + } + for (const name of Object.keys(manifest.scripts ?? {})) { + if (forbiddenLifecycle.has(name)) fail(`forbidden lifecycle script: ${name}`); + } + if (Object.keys(manifest.scripts ?? {}).length > 0) { + fail("release pnpm package must not contain scripts"); + } + if ( + JSON.stringify(manifest.bin) !== + JSON.stringify({ + "pliego-cssc": "bin/pliego-cssc.mjs", + "pliego-css-lsp": "bin/pliego-css-lsp.mjs", + }) + ) { + fail("pnpm binary launcher boundary drifted"); + } + if (manifest.repository?.url !== "git+https://github.com/celiumsai/pliegocss.git") { + fail("pnpm package repository must be the canonical GitHub repository"); + } + if (Object.hasOwn(manifest.publishConfig ?? {}, "registry")) { + fail("pnpm package must not configure an npm registry"); + } + return manifest; +} + +export function validateCycloneDx(path, binaryName, version) { + const document = readJson(path, `${binaryName} SBOM`); + if ( + document.bomFormat !== "CycloneDX" || + document.specVersion !== "1.5" || + document.version !== 1 + ) { + fail(`${binaryName} SBOM must be CycloneDX JSON 1.5 document version 1`); + } + const component = document.metadata?.component; + if ( + !component || + component.name !== binaryName || + component.version !== version + ) { + fail(`${binaryName} SBOM metadata does not identify ${binaryName}@${version}`); + } + if (!Array.isArray(document.components)) { + fail(`${binaryName} SBOM components must be an array`); + } + return document; +} + +export function validateBinary(path, target) { + if (!existsSync(path)) fail(`native binary is missing: ${path}`); + const bytes = readFileSync(path); + if (bytes.byteLength < 64 * 1024) { + fail(`${target.id} binary is implausibly small: ${bytes.byteLength} bytes`); + } + const magic = bytes.subarray(0, 4).toString("hex"); + const valid = + (target.format === "pe" && magic.startsWith("4d5a")) || + (target.format === "elf" && magic === "7f454c46") || + (target.format === "mach-o" && new Set(["cffaedfe", "feedfacf"]).has(magic)); + if (!valid) fail(`${target.id} binary has invalid ${target.format} magic ${magic}`); + return { bytes: bytes.byteLength, sha256: sha256(bytes) }; +} + +export function assertChildPath(parent, child, role) { + const root = resolve(parent); + const candidate = resolve(child); + const local = relative(root, candidate); + if (!local || local.startsWith("..") || isAbsolute(local)) { + fail(`${role} must be a child of ${parent}`); + } + return candidate; +} + +export function safeFilename(value, role) { + if ( + typeof value !== "string" || + value === "." || + value === ".." || + basename(value) !== value || + !/^[A-Za-z0-9@._-]+$/u.test(value) + ) { + fail(`${role} must be a portable filename without path segments`); + } + return value; +} + +export function digest(value, role) { + if (typeof value !== "string" || !DIGEST.test(value)) { + fail(`${role} must be 64 lowercase SHA-256 digits`); + } + return value; +} diff --git a/scripts/rust-target.mjs b/scripts/rust-target.mjs new file mode 100644 index 0000000..a34c3fd --- /dev/null +++ b/scripts/rust-target.mjs @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { isAbsolute, join, resolve } from "node:path"; + +function absoluteTargetBase(root, env) { + const configured = env.PLIEGOCSS_TARGET_BASE ?? env.CARGO_TARGET_DIR; + if (!configured) return join(root, "target"); + return isAbsolute(configured) ? configured : resolve(root, configured); +} + +function safeSegment(value) { + return value.toLowerCase().replaceAll(/[^a-z0-9._-]+/gu, "-").replaceAll(/^-+|-+$/gu, ""); +} + +export function targetDirectoryForRustcIdentity(root, env, identity) { + const digest = createHash("sha256").update(identity).digest("hex"); + const release = identity.match(/^release:\s*(.+)$/mu)?.[1] ?? "unknown"; + const host = identity.match(/^host:\s*(.+)$/mu)?.[1] ?? `${process.platform}-${process.arch}`; + const segment = `${safeSegment(host)}-rustc-${safeSegment(release)}-${digest.slice(0, 16)}`; + return join(absoluteTargetBase(root, env), "toolchains", segment); +} + +function rustcIdentity(env, toolchain) { + const command = toolchain ? "rustup" : (env.RUSTC ?? "rustc"); + const args = toolchain ? ["run", toolchain, "rustc", "-Vv"] : ["-Vv"]; + const result = spawnSync(command, args, { + env, + encoding: "utf8", + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`cannot identify Rust toolchain (${command} ${args.join(" ")}): ${result.stderr}`); + } + return result.stdout.replaceAll("\r\n", "\n").trim(); +} + +export function isolatedCargoEnvironment(root, options = {}) { + const env = options.env ?? process.env; + if (env.PLIEGOCSS_ISOLATED_TARGET === "1" && !options.toolchain) return { ...env }; + const identity = rustcIdentity(env, options.toolchain); + const target = targetDirectoryForRustcIdentity(root, env, identity); + return { + ...env, + CARGO_TARGET_DIR: target, + PLIEGOCSS_TARGET_BASE: absoluteTargetBase(root, env), + PLIEGOCSS_ISOLATED_TARGET: "1", + PLIEGOCSS_RUSTC_IDENTITY_SHA256: createHash("sha256").update(identity).digest("hex"), + }; +} + +export function cargoTargetRoot(root, env = process.env) { + const configured = env.CARGO_TARGET_DIR; + if (!configured) return join(root, "target"); + return isAbsolute(configured) ? configured : resolve(root, configured); +} diff --git a/site/Cargo.lock b/site/Cargo.lock index dfa6fa1..b761734 100644 --- a/site/Cargo.lock +++ b/site/Cargo.lock @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "pliego-css" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-macros", @@ -277,7 +277,7 @@ dependencies = [ [[package]] name = "pliego-css-compiler" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "pliego-css-parser", @@ -287,11 +287,11 @@ dependencies = [ [[package]] name = "pliego-css-ir" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "pliego-css-macros" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-compiler", "pliego-css-ir", @@ -304,14 +304,14 @@ dependencies = [ [[package]] name = "pliego-css-parser" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", ] [[package]] name = "pliego-css-theme" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "pliego-css-ir", "sha2", @@ -356,6 +356,7 @@ dependencies = [ "pliego-css", "pliego-dom", "pliego-ssg", + "serde", "serde_json", ] diff --git a/site/Cargo.toml b/site/Cargo.toml index 69c8090..8e200e4 100644 --- a/site/Cargo.toml +++ b/site/Cargo.toml @@ -16,7 +16,8 @@ test = false bench = false [dependencies] -pliego-css = { path = "../crates/pliego-css", version = "=0.1.0-rc.2" } +pliego-css = { path = "../crates/pliego-css", version = "=0.1.0-rc.3" } pliego-dom = "=0.0.2" pliego-ssg = "=0.0.2" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/site/README.md b/site/README.md index a3737e1..0844f32 100644 --- a/site/README.md +++ b/site/README.md @@ -2,18 +2,32 @@ # PliegoCSS website -The official site is authored as escaped PliegoRS views and published by the -PliegoRS SSG. PliegoCSS `pc!` literals provide real compile-time style -identities and the same source is compiled into the shipped utility bundle. +The official site shell is authored as escaped PliegoRS views and published by +the PliegoRS SSG. Public documentation prose lives in `docs/site/**/*.md`; +`site/scripts/generate-docs.mjs` strictly parses those files and writes the +tracked `site/src/docs.generated.json` projection with the exact source path +and SHA-256 for every page. Rust consumes that projection and does not carry a +second hand-maintained document catalog. The release-readiness page is itself +generated from the schema-3 machine authority. + +PliegoCSS `pc!` literals provide real compile-time style identities and the +same Rust source is compiled into the shipped utility bundle. GSAP, Lenis, and Three.js run behind an explicit client lifecycle with reduced motion, Save-Data, visibility, and WebGL fallbacks. ```console pnpm site:build pnpm site:preview +pnpm check:site-docs pnpm check:site ``` +Run `node site/scripts/generate-docs.mjs --write` after an intentional Markdown +change. Build and validation use `--check` and fail if the tracked projection +drifts. Each Markdown file must keep the closed `pliegocss-site` metadata +header, one H1, a one-line summary, and sections shaped as +`## Title {#stable-id}` with one body paragraph and at most one code fence. + The generated site is written to `site/target/site/` and is not committed. The public deployment profile in `wrangler.jsonc` serves that exact directory from Cloudflare Workers Static Assets at `pliegocss.dev`, with preview URLs and diff --git a/site/i18n/es.json b/site/i18n/es.json index 58b647a..dcdf37d 100644 --- a/site/i18n/es.json +++ b/site/i18n/es.json @@ -265,6 +265,7 @@ "Explore all utility forms": "Explora todas las formas de utilidad", "Extend the seed": "Extiende el seed", "External": "Externo", + "External adoption research": "Investigación de adopción externa", "FAIL CLOSED / BEFORE THE BROWSER": "FALLAR EN CERRADO / ANTES DEL NAVEGADOR", "FIELD NOTE / CASCADE SYSTEMS": "NOTA DE CAMPO / SISTEMAS DE CASCADA", "Feed automation safely": "Alimenta la automatización de forma segura", @@ -432,7 +433,7 @@ "PCS005": "PCS005", "PENDING": "PENDIENTE", "PLIEGO OBJECT / SERIES 01": "PLIEGO OBJECT / SERIES 01", - "PLIEGOCSS / 0.1.0-RC.2 / PUBLIC PREVIEW": "PLIEGOCSS / 0.1.0-RC.2 / VISTA PREVIA PÚBLICA", + "PLIEGOCSS / 0.1.0-RC.3 / CANDIDATE": "PLIEGOCSS / 0.1.0-RC.3 / CANDIDATO", "PLIEGOCSS.DEV": "PLIEGOCSS.DEV", "POLICY": "POLÍTICA", "PREVIEW": "VISTA PREVIA", @@ -455,7 +456,7 @@ "Plan first. Authorize exact bytes.": "Planifica primero. Autoriza bytes exactos.", "Plans bind source and destination hashes. Apply and rollback use adjacent locks, pre-publication revalidation, backups, and group compensation.": "Los planes vinculan hashes de origen y destino. Apply y rollback usan locks adyacentes, revalidación previa a la publicación, respaldos y compensación de grupo.", "PliegoCSS": "PliegoCSS", - "PliegoCSS 0.1.0-rc.2 is public-preview software published as an exact-version crates.io compatibility unit and replayed with Rust 1.85. APIs may change before 1.0, and the release-readiness record remains the authority for promotion.": "PliegoCSS 0.1.0-rc.2 es software en vista previa pública publicado como una unidad de compatibilidad de versión exacta en crates.io y reproducido con Rust 1.85. Las APIs pueden cambiar antes de 1.0 y el registro de preparación de lanzamiento sigue siendo la autoridad para la promoción.", + "PliegoCSS 0.1.0-rc.3 is public-preview candidate software under exact-source verification. Publication is not implied, APIs may change before 1.0, and the release-readiness record remains the authority for promotion.": "PliegoCSS 0.1.0-rc.3 es software candidato de vista previa pública bajo verificación de fuente exacta. No se presupone su publicación, las APIs pueden cambiar antes de 1.0 y el registro de preparación de lanzamiento sigue siendo la autoridad para la promoción.", "PliegoCSS compares payload, fresh-process compilation, Rust check cost, pruning, migration quality, and browser behavior without collapsing unlike workloads into one number.": "PliegoCSS compara payload, compilación en proceso fresco, costo de verificación de Rust, pruning, calidad de migración y comportamiento del navegador sin colapsar cargas distintas en un solo número.", "PliegoCSS folded cascade symbol": "Símbolo de cascada plegada de PliegoCSS", "PliegoCSS home": "Inicio de PliegoCSS", @@ -658,7 +659,7 @@ "Test, browser, toolchain, and version subprocesses use explicit deadlines and output limits while the repair lock is held.": "Los subprocesos de prueba, navegador, toolchain y versión usan plazos explícitos y límites de salida mientras se mantiene el bloqueo de reparación.", "The CSS is real.": "El CSS es real.", "The Cascade Folio": "El Folio de la Cascada", - "The complete 0.1.0-rc.2 compatibility unit is published on crates.io and replayed from the registry with Rust 1.85.": "La unidad de compatibilidad 0.1.0-rc.2 completa está publicada en crates.io y reproducida desde el registro con Rust 1.85.", + "The 0.1.0-rc.3 compatibility unit is under exact-source verification. Use this command after all nineteen crates are published and replayed with Rust 1.85.": "La unidad de compatibilidad 0.1.0-rc.3 está bajo verificación de fuente exacta. Usa este comando después de que los diecinueve crates se publiquen y se reproduzcan con Rust 1.85.", "The LSP shares parser and catalog contracts with the CLI while bounding subprocess time and output.": "El LSP comparte contratos de parser y catálogo con la CLI, al tiempo que acota el tiempo y la salida de los subprocesos.", "The baseline is usable before it is spectacular.": "La base es útil antes de ser espectacular.", "The boundary is honest.": "El límite es honesto.", @@ -668,7 +669,7 @@ "The classifier recognizes deliberate standard-CSS surfaces and reports unsupported constructs instead of guessing. A versioned corpus controls the claim.": "El clasificador reconoce superficies deliberadas de CSS estándar y reporta construcciones no compatibles en lugar de adivinar. Un corpus versionado controla esa afirmación.", "The command-line compiler can audit, transform, explain, bundle, and migrate standard CSS without adding a runtime dependency to the application.": "El compilador de línea de comandos puede auditar, transformar, explicar, empaquetar y migrar CSS estándar sin agregar una dependencia en tiempo de ejecución a la aplicación.", "The current PliegoCSS website uses no advertising cookies, cross-site tracking cookies, browser identifiers, or consent-requiring analytics.": "El sitio actual de PliegoCSS no usa cookies publicitarias, cookies de rastreo entre sitios, identificadores del navegador ni analítica que requiera consentimiento.", - "The 0.1.0-rc.2 compatibility unit is published on crates.io and replayed with Rust 1.85. Final 0.1.0 remains separately blocked by the readiness record.": "La unidad de compatibilidad 0.1.0-rc.2 está publicada en crates.io y reproducida con Rust 1.85. La versión final 0.1.0 sigue bloqueada por separado en el registro de preparación.", + "The 0.1.0-rc.3 compatibility unit is under exact-source verification. Publication and final 0.1.0 remain blocked by the readiness record.": "La unidad de compatibilidad 0.1.0-rc.3 está bajo verificación de fuente exacta. Su publicación y la versión final 0.1.0 siguen bloqueadas por el registro de preparación.", "The current static website does not create accounts, accept payments, run advertising, or build behavioral profiles.": "El sitio estático actual no crea cuentas, no acepta pagos, no publica publicidad ni crea perfiles de comportamiento.", "The inspector resolves each example state to an exact class generated by the repository’s compiler during the site build.": "El inspector resuelve cada estado de ejemplo a una clase exacta generada por el compilador del repositorio durante la compilación del sitio.", "The interaction is real.": "La interacción es real.", @@ -762,6 +763,7 @@ "Visible utilities and standard CSS": "Utilidades visibles y CSS estándar", "Visible utilities. Evidence underneath.": "Utilidades visibles. Evidencia abajo.", "Vite, webpack, esbuild, CI systems, and custom pipelines consume explicit CSS, manifests, findings, maps, and asset plans.": "Vite, webpack, esbuild, sistemas CI y pipelines personalizados consumen CSS, manifiestos, hallazgos, mapas y planes de recursos explícitos.", + "Voluntary G7 interviews, incident reports, and pilots are handled through private correspondence. Before any material is admitted, PliegoCSS records affirmative research and public-redacted-record consent, assigns pseudonymous identifiers, keeps raw material in private custody, and publishes only reviewed redacted fields plus a SHA-256 digest. A recruitment comment or email alone is not consent and is not evidence.": "Las entrevistas, los informes de incidentes y los pilotos voluntarios de G7 se gestionan mediante correspondencia privada. Antes de admitir cualquier material, PliegoCSS registra consentimiento afirmativo para investigación y para el registro público redactado, asigna identificadores seudónimos, mantiene el material original bajo custodia privada y publica únicamente campos redactados y revisados junto con un resumen SHA-256. Un comentario de reclutamiento o un correo por sí solo no constituye consentimiento ni evidencia.", "WHAT THESE DEMOS PROVE": "LO QUE ESTAS DEMOS DEMUESTRAN", "WSL DTCG watch repetitions kept the last valid group": "Las repeticiones de watch de WSL DTCG conservaron el último grupo válido", "Watch inputs and output": "Observar entradas y salida", @@ -789,7 +791,7 @@ "pc! accepts one visible string literal. The macro parses semantics, rejects conflicting slots, and returns a sealed Style.": "pc! acepta un único literal de cadena visible. El macro analiza la semántica, rechaza slots en conflicto y devuelve un Style sellado.", "pc!, pcx!, identity, and lineage.": "pc!, pcx!, identidad y linaje.", "pcx! compiles every visible branch combination and selects one identity at runtime. Independent clauses that can collide are rejected.": "pcx! compila cada combinación visible de ramas y selecciona una identidad en runtime. Las cláusulas independientes que pueden colisionar se rechazan.", - "pliego-cssc 0.1.0-rc.2": "pliego-cssc 0.1.0-rc.2", + "pliego-cssc 0.1.0-rc.3": "pliego-cssc 0.1.0-rc.3", "real migration roles identified with 0 false positives and 0 false negatives in the reviewed corpus": "roles reales de migración identificados con 0 falsos positivos y 0 falsos negativos en el corpus revisado", "remove one utility or move it to a narrower variant": "elimina una utilidad o muévela a una variante más específica", "route replayed": "route reproducida", diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs index 6d48f4b..6b2e398 100644 --- a/site/scripts/build.mjs +++ b/site/scripts/build.mjs @@ -65,6 +65,33 @@ function runCaptured(command, args, options = {}) { }); } +function runPliegoCssCaptured(args) { + if (process.platform === "win32" && existsSync(applicationControlMarker)) { + const translated = args.map((argument) => + /^[A-Za-z]:[\\/]/u.test(argument) ? windowsToWsl(argument) : argument, + ); + return runCaptured("wsl.exe", [ + "--cd", + windowsToWsl(root), + "-e", + wslPliegoCssExecutable(), + ...translated, + ]); + } + return runCaptured(cli, args); +} + +function runPliegoCss(args) { + const result = runPliegoCssCaptured(args); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`pliego-cssc failed with ${result.status}\n${result.stderr ?? ""}`); + } + process.stdout.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + return result.stdout ?? ""; +} + function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } @@ -132,10 +159,9 @@ function prepareAssets() { } function buildPliegoCss() { - run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"]); const outputCss = join(generated, "pliegocss.css"); const manifest = join(generated, "pliegocss.manifest.json"); - run(cli, [ + const arguments_ = [ "compile", "--source", join(siteRoot, "src"), @@ -145,7 +171,31 @@ function buildPliegoCss() { outputCss, "--manifest", manifest, - ]); + ]; + if (process.platform === "win32" && existsSync(applicationControlMarker)) { + buildPliegoCssWithWsl(outputCss, manifest); + } else { + run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"]); + const result = runCaptured(cli, arguments_); + const diagnostics = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.error || result.status !== 0) { + if ( + process.platform === "win32" && + (result.error || diagnostics.includes("Application Control policy has blocked this file")) + ) { + mkdirSync(dirname(applicationControlMarker), { recursive: true }); + writeFileSync(applicationControlMarker, "Windows executable launch blocked; use verified WSL build.\n"); + buildPliegoCssWithWsl(outputCss, manifest); + } else if (result.error) { + throw result.error; + } else { + throw new Error(`pliego-cssc failed with ${result.status}\n${diagnostics}`); + } + } else { + process.stdout.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + } + } const manifestDocument = JSON.parse(readFileSync(manifest, "utf8")); if ( manifestDocument.cssSha256 !== sha256(readFileSync(outputCss)) || @@ -231,7 +281,7 @@ function generateLaboratory() { mkdirSync(runtime, { recursive: true }); const cssPath = join(runtime, "laboratory.css"); const manifestPath = join(runtime, "laboratory.manifest.json"); - run(cli, [ + runPliegoCss([ "compile", ...combinations.flatMap((combination) => ["--style", combination.input]), "--seed", @@ -293,7 +343,7 @@ function generateLaboratory() { label: "Interactive action", }, ].map((entry) => { - const result = runCaptured(cli, [ + const result = runPliegoCssCaptured([ "explain", "--style", entry.input, @@ -329,7 +379,7 @@ function generateLaboratory() { input: "p-4 md:p-6", }, ].map((entry) => { - const result = runCaptured(cli, [ + const result = runPliegoCssCaptured([ "--diagnostic-format", "json", "check", @@ -366,7 +416,7 @@ function generateLaboratory() { `${JSON.stringify( { schemaVersion: 1, - generatedBy: "pliego-cssc 0.1.0-rc.2", + generatedBy: "pliego-cssc 0.1.0-rc.3", corpusBoundary: "Bounded build-time corpus; the browser does not reimplement the compiler.", dimensions, @@ -384,7 +434,7 @@ function generateLaboratory() { function generateCatalog() { const output = join(generated, "catalog.json"); - run(cli, [ + runPliegoCss([ "catalog", "--seed", "--format", @@ -515,6 +565,54 @@ function buildSiteWithWsl() { ]); } +function buildPliegoCssWithWsl(outputCss, manifest) { + const home = wslHome(); + const cargo = `${home}/.cargo/bin/cargo`; + const wslRoot = windowsToWsl(root); + const wslTarget = wslPliegoCssTarget(); + const environment = [ + `PATH=${home}/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, + `CARGO_TARGET_DIR=${wslTarget}`, + ]; + run("wsl.exe", [ + "--cd", + wslRoot, + "-e", + "env", + ...environment, + cargo, + "+1.85.0", + "build", + "--locked", + "-p", + "pliego-cssc", + ]); + run("wsl.exe", [ + "--cd", + wslRoot, + "-e", + `${wslTarget}/debug/pliego-cssc`, + "compile", + "--source", + windowsToWsl(join(siteRoot, "src")), + "--seed", + "--theme", + "--output", + windowsToWsl(outputCss), + "--manifest", + windowsToWsl(manifest), + ]); +} + +function wslPliegoCssTarget() { + const checkoutKey = sha256(Buffer.from(root, "utf8")).slice(0, 16); + return `${wslHome()}/.cache/pliegocss/site-css-${checkoutKey}`; +} + +function wslPliegoCssExecutable() { + return `${wslPliegoCssTarget()}/debug/pliego-cssc`; +} + function buildSite() { rmSync(output, { recursive: true, force: true }); if (process.platform === "win32" && existsSync(applicationControlMarker)) { @@ -552,6 +650,7 @@ function buildSite() { } } +run(process.execPath, [join(siteRoot, "scripts", "generate-docs.mjs"), "--check"]); prepareAssets(); buildPliegoCss(); generateLaboratory(); diff --git a/site/scripts/check-site.mjs b/site/scripts/check-site.mjs index 0785aac..513b108 100644 --- a/site/scripts/check-site.mjs +++ b/site/scripts/check-site.mjs @@ -81,6 +81,8 @@ function staticContract() { "docs/integrations/pliegors/index.html", "docs/tooling/repair/index.html", "docs/reference/diagnostics/index.html", + "docs/release-readiness/index.html", + "docs/external-adoption/index.html", "docs/utilities/index.html", "playground/index.html", "examples/index.html", @@ -97,6 +99,8 @@ function staticContract() { "es/index.html", "es/docs/index.html", "es/docs/getting-started/index.html", + "es/docs/release-readiness/index.html", + "es/docs/external-adoption/index.html", "es/docs/utilities/index.html", "es/playground/index.html", "es/examples/index.html", @@ -202,8 +206,8 @@ function staticContract() { "utf8", ); if ( - !englishTerms.includes("public-preview software") || - !spanishTerms.includes("software en vista previa pública") || + !englishTerms.includes("public-preview candidate software") || + !spanishTerms.includes("software candidato de vista previa pública") || !spanishTerms.includes('/gu)?.length ?? 0; if ( - sitemapUrls !== 88 || + sitemapUrls !== 92 || !sitemap.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"') || !sitemap.includes("https://pliegocss.dev/es/legal/privacy/") ) { fail(`bilingual sitemap drifted (${sitemapUrls} URLs)`); } + const readiness = JSON.parse( + readFileSync(join(root, "docs", "product", "release-readiness-0.1.0.json"), "utf8"), + ); + const generatedDocs = JSON.parse( + readFileSync(join(siteRoot, "src", "docs.generated.json"), "utf8"), + ); + const readinessDocument = generatedDocs.documents.find( + (document) => document.route === "/docs/release-readiness/", + ); + const externalAdoptionDocument = generatedDocs.documents.find( + (document) => document.route === "/docs/external-adoption/", + ); + const readinessHtml = readFileSync( + join(output, "docs", "release-readiness", "index.html"), + "utf8", + ); + if ( + !readinessDocument || + !readinessHtml.includes(readiness.source.commit) || + !readinessHtml.includes(readiness.source.gitTree) || + !readinessHtml.includes(readinessDocument.sourcePath) || + !readinessHtml.includes(readinessDocument.sourceSha256) || + !readinessHtml.includes("Current blockers") + ) { + fail("release readiness route is not bound to the generated Markdown authority"); + } + const externalAdoptionHtml = readFileSync( + join(output, "docs", "external-adoption", "index.html"), + "utf8", + ); + if ( + !externalAdoptionDocument || + !externalAdoptionHtml.includes(externalAdoptionDocument.sourcePath) || + !externalAdoptionHtml.includes(externalAdoptionDocument.sourceSha256) || + !externalAdoptionHtml.includes("External adoption is a measured gate") || + !externalAdoptionHtml.includes("Applications are open") || + !externalAdoptionHtml.includes("https://github.com/celiumsai/pliegocss/issues/7") || + !externalAdoptionHtml.includes("Interviews: 0/10") || + !externalAdoptionHtml.includes("Tailwind CSS 3.4.19 or 4.3.3") + ) { + fail("external adoption route is not bound to the G7 Markdown authority"); + } + const privacyHtml = readFileSync( + join(output, "legal", "privacy", "index.html"), + "utf8", + ); + const spanishPrivacyHtml = readFileSync( + join(output, "es", "legal", "privacy", "index.html"), + "utf8", + ); + if ( + !privacyHtml.includes("External adoption research") || + !privacyHtml.includes("A recruitment comment or email alone is not consent") || + !spanishPrivacyHtml.includes("Investigación de adopción externa") || + !spanishPrivacyHtml.includes("Un comentario de reclutamiento") || + !spanishPrivacyHtml.includes(' server.listen(0, "127.0.0.1", resolveListen)); const port = server.address().port; const profile = mkdtempSync(join(tmpdir(), "pliegocss-site-cdp-")); - const debugPort = 10500 + Math.floor(Math.random() * 500); + const debugPortFile = join(profile, "DevToolsActivePort"); + const chromeStderr = []; + let childError; const child = spawn( browser, [ "--headless=new", - `--remote-debugging-port=${debugPort}`, + "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--disable-default-apps", + "--disable-dev-shm-usage", "about:blank", ], - { stdio: "ignore" }, + { stdio: ["ignore", "ignore", "pipe"], windowsHide: true }, ); + child.stderr.on("data", (chunk) => { + chromeStderr.push(chunk); + if (chromeStderr.length > 32) chromeStderr.shift(); + }); + child.once("error", (error) => { + childError = error; + }); try { + let debugPort; + for (let attempt = 0; attempt < 300; attempt += 1) { + if (existsSync(debugPortFile)) { + const candidate = Number.parseInt( + readFileSync(debugPortFile, "utf8").split(/\r?\n/u)[0], + 10, + ); + if (Number.isInteger(candidate) && candidate > 0 && candidate <= 65_535) { + debugPort = candidate; + break; + } + } + if (childError || child.exitCode !== null) break; + await delay(100); + } + if (!debugPort) { + const stderr = Buffer.concat(chromeStderr).toString("utf8").trim().slice(-2_000); + fail( + `Chrome CDP bootstrap unavailable (browser=${browser}, exit=${ + child.exitCode ?? "running" + }, error=${childError?.message ?? "none"}, stderr=${JSON.stringify(stderr)})`, + ); + } let version; - for (let attempt = 0; attempt < 80; attempt += 1) { + for (let attempt = 0; attempt < 100; attempt += 1) { try { version = await ( await fetch(`http://127.0.0.1:${debugPort}/json/version`) @@ -334,7 +430,13 @@ async function browserContract() { await delay(100); } } - if (!version) fail("Chrome CDP endpoint unavailable"); + if (!version) { + fail( + `Chrome CDP endpoint unavailable on allocated port ${debugPort} (exit=${ + child.exitCode ?? "running" + })`, + ); + } let target; for (let attempt = 0; attempt < 60; attempt += 1) { const targets = await ( @@ -651,7 +753,7 @@ async function browserContract() { })()`, }); if ( - docs.result.value.items < 29 || + docs.result.value.items < 30 || docs.result.value.groups !== 8 || docs.result.value.visible !== 1 || docs.result.value.visibleGroups !== 1 diff --git a/site/scripts/generate-docs.mjs b/site/scripts/generate-docs.mjs new file mode 100644 index 0000000..cd5a134 --- /dev/null +++ b/site/scripts/generate-docs.mjs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { + existsSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const siteRoot = resolve(dirname(scriptPath), ".."); +const root = resolve(siteRoot, ".."); +const docsRoot = resolve(root, "docs/site"); +const outputPath = resolve(siteRoot, "src/docs.generated.json"); +const metadataFields = [ + "schemaVersion", + "route", + "category", + "eyebrow", + "order", +]; + +function fail(sourcePath, message) { + throw new Error(`site docs: ${sourcePath}: ${message}`); +} + +function exactFields(value, expected, sourcePath, role) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(sourcePath, `${role} must be an object`); + } + const actual = Object.keys(value).sort(); + if (JSON.stringify(actual) !== JSON.stringify([...expected].sort())) { + fail(sourcePath, `${role} fields drifted: ${actual.join(", ")}`); + } +} + +function portablePath(path) { + return path.split(sep).join("/"); +} + +function markdownFiles(directory) { + const files = []; + for (const entry of readdirSync(directory).sort()) { + const path = resolve(directory, entry); + if (statSync(path).isDirectory()) files.push(...markdownFiles(path)); + else if (entry.endsWith(".md")) files.push(path); + } + return files; +} + +function oneLine(lines, sourcePath, role) { + if (lines.length !== 1 || lines[0].trim() !== lines[0] || !lines[0]) { + fail(sourcePath, `${role} must be one non-empty paragraph line`); + } + return lines[0]; +} + +export function parseDocument(bytes, sourcePath = "document.md") { + const source = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes, "utf8"); + const text = source.toString("utf8").replaceAll("\r\n", "\n"); + const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n"); + if ( + lines[0] !== "" || + lines[3] !== "" + ) { + fail(sourcePath, "expected the strict three-line pliegocss-site header"); + } + let metadata; + try { + metadata = JSON.parse(lines[1]); + } catch (error) { + fail(sourcePath, `invalid metadata JSON: ${error.message}`); + } + exactFields(metadata, metadataFields, sourcePath, "metadata"); + if (metadata.schemaVersion !== 1) fail(sourcePath, "unsupported metadata schema"); + if (!/^\/docs\/[a-z0-9][a-z0-9/-]*\/$/u.test(metadata.route)) { + fail(sourcePath, "route must be a canonical /docs/.../ path"); + } + if ( + typeof metadata.category !== "string" || + !metadata.category || + typeof metadata.eyebrow !== "string" || + !metadata.eyebrow || + !Number.isSafeInteger(metadata.order) || + metadata.order < 1 + ) { + fail(sourcePath, "category, eyebrow, or order is invalid"); + } + const titleMatch = /^# (.+)$/u.exec(lines[4] ?? ""); + if (!titleMatch || lines[5] !== "") fail(sourcePath, "expected one H1 followed by a blank line"); + const summary = oneLine([lines[6] ?? ""], sourcePath, "summary"); + if (lines[7] !== "") fail(sourcePath, "summary must be followed by a blank line"); + + const sections = []; + const sectionIds = new Set(); + let index = 8; + while (index < lines.length) { + const heading = /^## (.+) \{#([a-z0-9][a-z0-9-]*)\}$/u.exec(lines[index] ?? ""); + if (!heading) fail(sourcePath, `unsupported structure at line ${index + 1}`); + if (sectionIds.has(heading[2])) fail(sourcePath, `duplicate section id ${heading[2]}`); + sectionIds.add(heading[2]); + if (lines[index + 1] !== "") fail(sourcePath, `section ${heading[2]} needs a blank line`); + const body = oneLine([lines[index + 2] ?? ""], sourcePath, `section ${heading[2]} body`); + index += 3; + let code = null; + if (index < lines.length) { + if (lines[index] !== "") fail(sourcePath, `section ${heading[2]} body must end with a blank line`); + index += 1; + if ((lines[index] ?? "").startsWith("```")) { + if (!/^```[a-z0-9-]*$/u.test(lines[index])) { + fail(sourcePath, `section ${heading[2]} has an invalid code fence`); + } + index += 1; + const codeLines = []; + while (index < lines.length && lines[index] !== "```") { + codeLines.push(lines[index]); + index += 1; + } + if (lines[index] !== "```") fail(sourcePath, `section ${heading[2]} code fence is open`); + if (codeLines.length === 0) fail(sourcePath, `section ${heading[2]} code is empty`); + code = codeLines.join("\n"); + index += 1; + if (index < lines.length) { + if (lines[index] !== "") fail(sourcePath, `section ${heading[2]} code must end with a blank line`); + index += 1; + } + } + } + sections.push({ id: heading[2], title: heading[1], body, code }); + } + if (sections.length === 0) fail(sourcePath, "at least one section is required"); + return { + sourcePath, + sourceSha256: `sha256:${createHash("sha256").update(source).digest("hex")}`, + route: metadata.route, + title: titleMatch[1], + category: metadata.category, + eyebrow: metadata.eyebrow, + summary, + order: metadata.order, + sections, + }; +} + +export function generateManifest() { + if (!existsSync(docsRoot)) throw new Error("site docs: docs/site does not exist"); + const documents = markdownFiles(docsRoot).map((path) => { + const sourcePath = portablePath(relative(root, path)); + if (!sourcePath.startsWith("docs/site/") || sourcePath.includes("../")) { + fail(sourcePath, "source escaped docs/site"); + } + return parseDocument(readFileSync(path), sourcePath); + }); + documents.sort((left, right) => left.order - right.order || left.route.localeCompare(right.route)); + const routes = new Set(); + for (const [index, document] of documents.entries()) { + if (document.order !== index + 1) { + fail(document.sourcePath, `order must be contiguous; expected ${index + 1}`); + } + if (routes.has(document.route)) fail(document.sourcePath, `duplicate route ${document.route}`); + routes.add(document.route); + } + return { schemaVersion: 1, kind: "pliegocss-site-markdown", documents }; +} + +export function renderManifest() { + return `${JSON.stringify(generateManifest(), null, 2)}\n`; +} + +function main() { + const args = process.argv.slice(2); + if (args.length > 1 || (args[0] && !["--check", "--write"].includes(args[0]))) { + throw new Error("usage: node site/scripts/generate-docs.mjs [--check|--write]"); + } + const mode = args[0] ?? "--check"; + const rendered = renderManifest(); + if (mode === "--write") { + writeFileSync(outputPath, rendered, "utf8"); + process.stdout.write(`wrote ${outputPath}\n`); + return; + } + if (!existsSync(outputPath) || readFileSync(outputPath, "utf8").replaceAll("\r\n", "\n") !== rendered) { + throw new Error("site docs: generated manifest drifted; run with --write"); + } + const manifest = JSON.parse(rendered); + process.stdout.write(`site Markdown manifest: current (${manifest.documents.length} documents)\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) main(); diff --git a/site/scripts/generate-docs.test.mjs b/site/scripts/generate-docs.test.mjs new file mode 100644 index 0000000..94087ca --- /dev/null +++ b/site/scripts/generate-docs.test.mjs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { generateManifest, parseDocument } from "./generate-docs.mjs"; + +const manifest = generateManifest(); +assert.equal(manifest.schemaVersion, 1); +assert.equal(manifest.kind, "pliegocss-site-markdown"); +assert.equal(manifest.documents.length, 31); +assert.equal(new Set(manifest.documents.map((document) => document.route)).size, 31); +assert.equal(manifest.documents.at(-1).route, "/docs/external-adoption/"); +assert.match(manifest.documents.at(-1).sourceSha256, /^sha256:[a-f0-9]{64}$/u); + +const sample = ` + +# Sample title + +Sample summary. + +## First section {#first} + +Sample body. +`; +assert.equal(parseDocument(sample).sections[0].body, "Sample body."); +assert.throws( + () => parseDocument(sample.replace("## First section", "### First section")), + /unsupported structure/u, +); +assert.throws( + () => parseDocument(sample.replace('"order":1', '"order":1,"extra":true')), + /metadata fields drifted/u, +); +process.stdout.write("site Markdown source contracts: pass\n"); diff --git a/site/src/docs.generated.json b/site/src/docs.generated.json new file mode 100644 index 0000000..4954c47 --- /dev/null +++ b/site/src/docs.generated.json @@ -0,0 +1,942 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-site-markdown", + "documents": [ + { + "sourcePath": "docs/site/getting-started.md", + "sourceSha256": "sha256:c6dc6f90c559b7ecb0a8202f6f6cdd20b2865cc42b122074623555ad96f64962", + "route": "/docs/getting-started/", + "title": "Start with the CSS you already have.", + "category": "Start", + "eyebrow": "Getting started", + "summary": "Audit first, compile when it adds value, and keep the browser contract ordinary.", + "order": 1, + "sections": [ + { + "id": "install", + "title": "Install the release candidate", + "body": "The 0.1.0-rc.3 compatibility unit is under exact-source verification. Use this command after all nineteen crates are published and replayed with Rust 1.85.", + "code": "cargo install pliego-cssc --version '=0.1.0-rc.3' --locked" + }, + { + "id": "audit", + "title": "Audit ordinary CSS", + "body": "Start without changing authoring. The analyzer emits human, canonical JSON, or SARIF findings and fails closed on unknown surfaces.", + "code": "pliego-cssc audit --input app.css \\\n --targets baseline-widely --format human" + }, + { + "id": "compile", + "title": "Add typed identities selectively", + "body": "Rust literals are validated at compile time and compile to static standards-compliant CSS. There is no styling runtime.", + "code": "use pliego_css::{pc, Style};\n\nconst BUTTON: Style = pc!(\n \"inline-flex gap-2 rounded-lg bg-accent px-4 py-2\"\n);" + } + ] + }, + { + "sourcePath": "docs/site/typed-styles.md", + "sourceSha256": "sha256:69cc2ec5e262871914e68e7f49a0f5502b748d6872c3401ac7147f350a06223a", + "route": "/docs/typed-styles/", + "title": "Types at author time. CSS at run time.", + "category": "Core", + "eyebrow": "Typed styles", + "summary": "Stable 128-bit identities connect Rust source, emitted classes, manifests, and physical declarations.", + "order": 2, + "sections": [ + { + "id": "pc", + "title": "One literal, one stable identity", + "body": "pc! accepts one visible string literal. The macro parses semantics, rejects conflicting slots, and returns a sealed Style.", + "code": "const CARD: Style = pc!(\n \"grid gap-4 rounded-xl border border-line p-6 shadow-md\"\n);" + }, + { + "id": "pcx", + "title": "Visible state spaces", + "body": "pcx! compiles every visible branch combination and selects one identity at runtime. Independent clauses that can collide are rejected.", + "code": "let style = pcx!(\n \"block\",\n if active { \"opacity-100\" } else { \"opacity-50\" }\n);" + }, + { + "id": "lineage", + "title": "Follow the physical effect", + "body": "Manifest schema 5 joins semantic declarations to final rules, conditions, byte ranges, and synthesized effects such as box-shadow.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/audit-and-transform.md", + "sourceSha256": "sha256:1ae15b85458272dbdf5bf697ec4d5161c5d995961bc39f3c9c08c72c79b29b84", + "route": "/docs/audit-and-transform/", + "title": "Standards-first, not framework-first.", + "category": "Standard CSS", + "eyebrow": "Audit and transform", + "summary": "Analyze standard CSS, enforce policy, and transform output without claiming ownership of authored source.", + "order": 3, + "sections": [ + { + "id": "classifier", + "title": "Bounded classification", + "body": "The classifier recognizes deliberate standard-CSS surfaces and reports unsupported constructs instead of guessing. A versioned corpus controls the claim.", + "code": null + }, + { + "id": "transform", + "title": "Deterministic transformation", + "body": "Target browsers, minify, and check output drift. Add --check in CI to remain read-only.", + "code": "pliego-cssc transform-css --input app.css \\\n --output dist/app.css --targets modern --format minified" + }, + { + "id": "policy", + "title": "Compatibility, accessibility, and budgets", + "body": "Policy documents bind findings to explicit targets and limits. The same canonical findings feed terminals, JSON automation, and SARIF.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/evidence.md", + "sourceSha256": "sha256:3deb73bd0b795902d702ca0cf877a560e6db99c35817c21423466a571f6e48ef", + "route": "/docs/evidence/", + "title": "A green build should explain itself.", + "category": "Evidence", + "eyebrow": "Evidence and provenance", + "summary": "Artifacts carry hashes, source lineage, graphs, findings, and receipts designed for replay.", + "order": 4, + "sections": [ + { + "id": "manifest", + "title": "Canonical manifests", + "body": "Style identity, class identity, theme identity, origins, conditions, physical declarations, and final CSS hashes live in a deterministic document.", + "code": "pliego-cssc compile --source src --seed --theme \\\n --output dist/app.css --manifest dist/app.manifest.json" + }, + { + "id": "receipts", + "title": "Receipt-last publication", + "body": "Controlled operations stage output, lock the destination, verify inputs, publish artifacts, and write the receipt last.", + "code": null + }, + { + "id": "benchmarks", + "title": "Measured, inherited, pending, uncertain", + "body": "Release readiness preserves the evidence class. Old hosted evidence never silently passes changed source.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/migration.md", + "sourceSha256": "sha256:6d3b48362eb1ef99e432a923ce2ede72ae231b9272199571827f56e6801ed6b3", + "route": "/docs/migration/", + "title": "Adopt without surrendering the exit.", + "category": "Migration", + "eyebrow": "Controlled migration", + "summary": "Inventory first, generate bounded plans, preserve originals, and detect drift immediately before publication.", + "order": 5, + "sections": [ + { + "id": "inventory", + "title": "Read-only discovery", + "body": "Tailwind, Sass, CSS Modules, and standard CSS can be inventoried before any replacement plan exists.", + "code": "pliego-cssc migration-project-plan ./migration-declaration.json" + }, + { + "id": "reversible", + "title": "Reversible replacements", + "body": "Plans bind source and destination hashes. Apply and rollback use adjacent locks, pre-publication revalidation, backups, and group compensation.", + "code": null + }, + { + "id": "limits", + "title": "No speculative codemods", + "body": "Unsupported or context-dependent constructs stay in the inventory. The migration layer does not infer framework semantics it cannot prove.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/integrations/pliegors.md", + "sourceSha256": "sha256:dbfe2999c765e1ec3bd1e8868e474fac69bb735cef042596a6f5322b81b7fd29", + "route": "/docs/integrations/pliegors/", + "title": "Native together. Independent by contract.", + "category": "Integrations", + "eyebrow": "PliegoRS integration", + "summary": "PliegoRS is the first native consumer, not the only target. It supplies topology; PliegoCSS supplies style identity and artifacts.", + "order": 6, + "sections": [ + { + "id": "boundary", + "title": "Explicit ownership", + "body": "PliegoRS owns routes, pages, assets, and browser lifecycle. PliegoCSS owns parsing, semantic conflicts, CSS, manifests, and policy findings.", + "code": null + }, + { + "id": "bundles", + "title": "Route and island bundles", + "body": "A reachability sidecar projects application-owned topology into selected CSS bundles without PliegoCSS pretending to understand the framework.", + "code": null + }, + { + "id": "proof", + "title": "Replayed from published crates", + "body": "The maintained fixture locks PliegoRS 0.0.2 registry packages and verifies the remote-reachable framework revision separately.", + "code": "PLIEGORS_ROOT=/path/to/pliegors pnpm integration:pliegors" + } + ] + }, + { + "sourcePath": "docs/site/installation.md", + "sourceSha256": "sha256:c5150bb64061e203667b655a155dd3054b48e3b88a2ea6d3ec760978a7ec5796", + "route": "/docs/installation/", + "title": "Choose an adoption surface.", + "category": "Start", + "eyebrow": "Installation", + "summary": "Install the CLI for any project, add the Rust crates only when typed authoring belongs in the application.", + "order": 7, + "sections": [ + { + "id": "cli", + "title": "CLI-only adoption", + "body": "The command-line compiler can audit, transform, explain, bundle, and migrate standard CSS without adding a runtime dependency to the application.", + "code": "cargo install pliego-cssc --version '=0.1.0-rc.3' --locked\npliego-cssc --version" + }, + { + "id": "rust", + "title": "Typed Rust authoring", + "body": "Add pliego-css and its macro crate to applications that want compile-time style identities. The emitted result remains ordinary CSS.", + "code": "[dependencies]\npliego-css = \"=0.1.0-rc.3\"" + }, + { + "id": "pin", + "title": "Pin the exact release identity", + "body": "Before the public release, use the signed project tag plus Cargo.lock. Do not track a moving branch in a production build.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/first-audit.md", + "sourceSha256": "sha256:d6476ad1b40afd7211cb9adbbc542384baeba1f15265c62a0e056641f9e3cfa7", + "route": "/docs/first-audit/", + "title": "Learn before changing.", + "category": "Start", + "eyebrow": "First audit", + "summary": "Run a read-only CSS audit, inspect canonical findings, then decide whether transformation or policy is justified.", + "order": 8, + "sections": [ + { + "id": "human", + "title": "Read the first report", + "body": "Human output is optimized for local diagnosis. Every finding carries a stable code and source location rather than an opaque score.", + "code": "pliego-cssc audit --input app.css \\\n --targets baseline-widely --format human" + }, + { + "id": "automation", + "title": "Feed automation safely", + "body": "Canonical JSON and SARIF preserve the same finding model for CI, code scanning, and archival evidence.", + "code": "pliego-cssc audit --input app.css --targets modern --format sarif > findings.sarif" + }, + { + "id": "unknown", + "title": "Unknown means unknown", + "body": "The standard-CSS classifier is bounded. Unsupported selectors, properties, or values fail closed under compatibility policy rather than being treated as safe.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/editor-setup.md", + "sourceSha256": "sha256:6c222319c97d9648c1146dbb5c5ea6911531a39283aff12b1bc64444a3daca54", + "route": "/docs/editor-setup/", + "title": "Put the compiler engine in the feedback loop.", + "category": "Start", + "eyebrow": "Editor setup", + "summary": "Use `pliego-css-lsp` for diagnostics, completion, hover evidence, and catalog-aware authoring without launching the build CLI.", + "order": 9, + "sections": [ + { + "id": "vscode", + "title": "VS Code extension", + "body": "The maintained extension launches `pliego-css-lsp`, discovers the project theme, and renders diagnostics in Rust source.", + "code": "pnpm --filter pliegocss-vscode package" + }, + { + "id": "generic", + "title": "Any LSP client", + "body": "Launch the server over stdio from the same workspace used by CI. The protocol stays editor-neutral.", + "code": "pliego-css-lsp --seed" + }, + { + "id": "safety", + "title": "Bound in-process work", + "body": "Document versions debounce for 150 ms, stale results are suppressed, semantic caches are bounded, and each document stops at 256 checks with `PCL002`.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/syntax.md", + "sourceSha256": "sha256:eca84634a88f97e387815fd73ae183b7c0b8c76218f4961056cc5f07e908846f", + "route": "/docs/syntax/", + "title": "A finite language with visible edges.", + "category": "Core", + "eyebrow": "Utility syntax", + "summary": "Utilities lower into semantic slots, conditions, and typed values before any CSS serialization occurs.", + "order": 10, + "sections": [ + { + "id": "shape", + "title": "Utility plus typed value", + "body": "Spacing, color, typography, layout, effects, and interactivity draw from one validated theme registry. Arbitrary escape hatches remain policy-visible.", + "code": "flex items-center gap-4 rounded-lg bg-surface px-4 py-2 text-sm" + }, + { + "id": "conditions", + "title": "Conditions are structure", + "body": "Responsive, state, attribute, container, layer, and media variants become normalized condition paths. Their order is not incidental string decoration.", + "code": "md:grid-cols-2 hover:bg-accent-strong aria-[expanded=true]:ring-2" + }, + { + "id": "important", + "title": "Importance follows the physical effect", + "body": "A trailing exclamation mark marks the semantic contribution important. Synthesized effects such as the composed box-shadow preserve that authority in final CSS and lineage.", + "code": "ring-2! shadow-md!" + } + ] + }, + { + "sourcePath": "docs/site/composition.md", + "sourceSha256": "sha256:bfa71645825196c5da9b03a52df6d01ddead2fb93d1afd900d10bbeb17cdf25e", + "route": "/docs/composition/", + "title": "Compose by meaning, not source order.", + "category": "Core", + "eyebrow": "Composition", + "summary": "Compatible slots commute. Conflicting slots require an explicit branch-over-base operation.", + "order": 11, + "sections": [ + { + "id": "commute", + "title": "Equivalent input, equivalent identity", + "body": "Utilities that occupy independent semantic slots canonicalize to the same identity even when authored in a different order.", + "code": "flex gap-4 p-6\np-6 flex gap-4" + }, + { + "id": "conflict", + "title": "Ambiguous lists are rejected", + "body": "p-4 p-6 does not mean last one wins. Both declarations claim the same slot under the same condition, so compilation stops.", + "code": "pliego-cssc check --style \"p-4 p-6\" --seed" + }, + { + "id": "override", + "title": "Overrides are explicit operations", + "body": "When an application intentionally replaces one semantic assignment with another, use composition so the override relationship remains visible and inspectable.", + "code": "pliego-cssc compile --compose \"p-4\" \"p-6\" --seed" + } + ] + }, + { + "sourcePath": "docs/site/variants.md", + "sourceSha256": "sha256:65640a6e4ecbb21cdb5a8a7e5d10aeeffeafac33491291365b7f1036f8c55b3c", + "route": "/docs/variants/", + "title": "Conditions without string-order folklore.", + "category": "Core", + "eyebrow": "Variants and conditions", + "summary": "Every variant joins a typed condition path with deterministic normalization and specificity behavior.", + "order": 12, + "sections": [ + { + "id": "responsive", + "title": "Responsive paths", + "body": "Theme breakpoints are validated once and emitted as ordered media conditions. Assignments at different breakpoints do not conflict.", + "code": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" + }, + { + "id": "state", + "title": "State and attributes", + "body": "Pseudo-class and bounded attribute variants stay in semantic IR so policy and physical lineage can explain their selectors.", + "code": "hover:bg-accent focus-visible:ring-2 data-[state=open]:opacity-100" + }, + { + "id": "layers", + "title": "Cascade layers", + "body": "Fixed layer variants model author-controlled precedence without allowing arbitrary source-order dependence to leak into identity.", + "code": "layer-components:bg-surface layer-utilities:p-4" + } + ] + }, + { + "sourcePath": "docs/site/themes.md", + "sourceSha256": "sha256:6a0abef1d4f797432dc0e8a0337993c324e212165133186a5cbe6591de30e224", + "route": "/docs/themes/", + "title": "Resolve tokens before styles.", + "category": "Core", + "eyebrow": "Themes and tokens", + "summary": "TOML themes and DTCG Resolver documents feed the same typed registry, identity, graph, and emitted variables.", + "order": 13, + "sections": [ + { + "id": "toml", + "title": "Extend the seed", + "body": "A project theme can override or add typed colors, spacing, radii, typography, and breakpoints without changing utility grammar.", + "code": "schema = 1\nextends = \"seed\"\n\n[tokens.color]\nbrand = \"oklch(62% 0.18 250)\"" + }, + { + "id": "dtcg", + "title": "Select DTCG contexts explicitly", + "body": "Resolver inputs are never auto-discovered. Modifier selections participate in configuration identity even when two contexts resolve to equal values.", + "code": "pliego-cssc compile --tokens product.resolver.json \\\n --token-input appearance=dark --style \"bg-brand\"" + }, + { + "id": "graph", + "title": "Publish the complete Token Graph", + "body": "Controlled output binds selected CSS to canonical token provenance, aliases, permutations, and reachability-aware usage evidence.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/standard-css/policies.md", + "sourceSha256": "sha256:4e7ad817de6b1f1a9192d4c069fad466ed83087e23ed3cdca16bc373935682bc", + "route": "/docs/standard-css/policies/", + "title": "Make browser support and budgets executable.", + "category": "Standard CSS", + "eyebrow": "Policies", + "summary": "Compatibility, accessibility, payload, package, route, and ownership policies turn expectations into reviewable inputs.", + "order": 14, + "sections": [ + { + "id": "compatibility", + "title": "Version the target", + "body": "baseline-widely and modern are frozen policy profiles with explicit browser and source-data identities. They do not drift silently with a dependency update.", + "code": "pliego-cssc compatibility --targets baseline-widely" + }, + { + "id": "budgets", + "title": "Bind budgets to owners", + "body": "Direct files can name budget subjects manually. Asset Plans use an ownership sidecar to aggregate package and composed-route costs without guessing responsibility.", + "code": "pliego-cssc audit --asset-plan dist/pliego.assets.json \\\n --ownership pliego.ownership.json --budget-policy budgets.json" + }, + { + "id": "accessibility", + "title": "Audit static accessibility contracts", + "body": "Contrast and interaction guards use explicit policy plus token evidence. Dynamic visual behavior still requires browser and assistive-technology testing.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/cascade.md", + "sourceSha256": "sha256:1ace78c08d0ff1f6c0f441f5e1ebae5be60dc1feb04fbeef54956f96fae5be57", + "route": "/docs/cascade/", + "title": "Explain the winning declaration.", + "category": "Standard CSS", + "eyebrow": "Cascade inspection", + "summary": "Inspect authored CSS by element and longhand to see matching selectors, precedence, and the final winner.", + "order": 15, + "sections": [ + { + "id": "query", + "title": "Ask one precise question", + "body": "Provide a CSS file, a bounded element description, and one longhand. The command reports candidates instead of simulating an entire browser.", + "code": "pliego-cssc explain-cascade --input app.css \\\n --element 'button#save.action' --property background-color" + }, + { + "id": "winner", + "title": "See why it won", + "body": "Origin, importance, layer, specificity, and source position are surfaced in comparison order with a stable machine-readable form.", + "code": null + }, + { + "id": "boundary", + "title": "Keep the model honest", + "body": "This is a static bounded inspector. Runtime DOM state, animation interpolation, adopted sheets, and script mutation remain browser responsibilities.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/manifests.md", + "sourceSha256": "sha256:b301fa80c8b248fb5a91e2c3f26fbcc448c1cf68540cffe39752ec364755d036", + "route": "/docs/manifests/", + "title": "Trace source intent into emitted bytes.", + "category": "Evidence", + "eyebrow": "Style manifests", + "summary": "Manifest schema 5 connects source origins, semantic assignments, final selectors, physical declarations, and CSS ranges.", + "order": 16, + "sections": [ + { + "id": "identity", + "title": "Identity chain", + "body": "StyleId identifies canonical semantics. Class identity, theme identity, configuration, and final CSS hash bind that style into one build.", + "code": "pliego-cssc compile --source src --manifest dist/app.manifest.json \\\n --manifest-version 5 --output dist/app.css" + }, + { + "id": "physical", + "title": "Physical lineage", + "body": "Generated custom properties and synthesized declarations retain contributors, importance, conditions, selector, property, value, and output byte range.", + "code": null + }, + { + "id": "versions", + "title": "Request the schema you consume", + "body": "Schema 3 supports style manifests, schema 4 adds reachability ownership, and schema 5 adds closed physical tracing. Consumers must pin their contract.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/control-artifacts.md", + "sourceSha256": "sha256:f80f80557d42574c8281bfcf6bb72ecd3884527f69ca1c81eb36d889a12a0e2d", + "route": "/docs/control-artifacts/", + "title": "Publish the receipt last.", + "category": "Evidence", + "eyebrow": "Control artifacts", + "summary": "Controlled builds stage related outputs, lock the destination, verify hashes, compensate failures, and expose one completed receipt.", + "order": 17, + "sections": [ + { + "id": "group", + "title": "One rollback-capable group", + "body": "CSS, source map, style manifest, Token Graph, findings, control manifest, and receipt are generated from one exact input snapshot.", + "code": "pliego-cssc compile --source src --output dist/app.css \\\n --manifest dist/app.manifest.json --control-dir dist" + }, + { + "id": "check", + "title": "Check without writing", + "body": "The same command with --check recomputes every artifact and fails on byte drift without taking publication locks or replacing files.", + "code": "pliego-cssc compile --source src --output dist/app.css \\\n --manifest dist/app.manifest.json --control-dir dist --check" + }, + { + "id": "receipt", + "title": "Completion is explicit", + "body": "Consumers treat the fixed receipt as the publication boundary. Its absence means the group is incomplete, even if individual files are present.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/critical-css.md", + "sourceSha256": "sha256:85e43a1f36e743a2fc84328847e4e72ff42ce97f8b048d5233f43c3394dd3f1e", + "route": "/docs/critical-css/", + "title": "Prove what rendered above the fold.", + "category": "Evidence", + "eyebrow": "Critical style evidence", + "summary": "Join observed browser behavior to project topology and retained style identity without letting an observation silently authorize removal.", + "order": 18, + "sections": [ + { + "id": "observe", + "title": "Observe a real route", + "body": "Capture viewport, route, interaction state, loaded assets, matched styles, and source identities from a browser run against the built application.", + "code": null + }, + { + "id": "join", + "title": "Join exact universes", + "body": "Evidence is accepted only when its project index, asset plan, reachability, and CSS identities match the build under review.", + "code": null + }, + { + "id": "authority", + "title": "Observation does not equal deletion", + "body": "A style unseen in one scope remains unknown outside that scope. Explicit retention and reachability policies control removal authority.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/migration/tailwind.md", + "sourceSha256": "sha256:f4aad2c2b31b7062736566d4c09690378354676239c5c039b6743794f8a42cf9", + "route": "/docs/migration/tailwind/", + "title": "Inventory Tailwind before translating it.", + "category": "Migration", + "eyebrow": "Tailwind migration", + "summary": "Recover the real utility surface, classify supported roles, and keep unsupported framework behavior visible.", + "order": 19, + "sections": [ + { + "id": "inventory", + "title": "Scan without replacement", + "body": "The inventory recognizes bounded class-bearing surfaces and reports candidates with source ranges. It does not rewrite template semantics during discovery.", + "code": "pliego-cssc migration-inventory tailwind src/App.tsx" + }, + { + "id": "plan", + "title": "Generate a hash-bound project plan", + "body": "A declaration selects roots and destinations. The plan records exact source and output hashes before any replacement can be applied.", + "code": "pliego-cssc migration-project-plan migration.json" + }, + { + "id": "gaps", + "title": "Preserve unsupported behavior", + "body": "Plugins, generated variants, arbitrary selectors, and runtime class construction remain explicit migration findings unless a project-owned adapter proves them.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/migration/sass.md", + "sourceSha256": "sha256:e12a4888068eed66e73fdc900c47b10d2587a0c2a451f3c67e0312ae7a68b80a", + "route": "/docs/migration/sass/", + "title": "Separate syntax conversion from semantic adoption.", + "category": "Migration", + "eyebrow": "Sass migration", + "summary": "Inventory variables, nesting, mixins, imports, and generated surfaces without pretending every Sass abstraction is a utility.", + "order": 20, + "sections": [ + { + "id": "inventory", + "title": "Map the source surface", + "body": "The inventory records statically visible Sass roles and unresolved dynamic behavior so the team can choose what stays authored CSS.", + "code": "pliego-cssc migration-inventory sass styles/main.scss" + }, + { + "id": "css-first", + "title": "Ordinary CSS is a valid destination", + "body": "Use the standard-CSS transformer for syntax and compatibility work. Adopt typed PliegoCSS styles only where identity and provenance pay for themselves.", + "code": null + }, + { + "id": "rollback", + "title": "Keep originals and rollback evidence", + "body": "Applied replacements preserve source backups and hash-bound rollback records. A changed destination aborts rather than overwriting concurrent edits.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/migration/css-modules.md", + "sourceSha256": "sha256:977a2a6184768213586d26b6ecd5da3b47ccc10ef0e88e96e26702a19af73752", + "route": "/docs/migration/css-modules/", + "title": "Preserve component ownership.", + "category": "Migration", + "eyebrow": "CSS Modules migration", + "summary": "Discover module exports and usage while keeping application-owned component topology separate from CSS semantics.", + "order": 21, + "sections": [ + { + "id": "inventory", + "title": "Inventory exported classes", + "body": "The scanner identifies bounded module class definitions and their source locations without assuming a particular bundler naming strategy.", + "code": "pliego-cssc migration-inventory css-modules src/Card.module.css" + }, + { + "id": "ownership", + "title": "Let the application own reachability", + "body": "Route and component reachability comes from the framework or product index, not from filename heuristics inside the CSS compiler.", + "code": null + }, + { + "id": "mixed", + "title": "Mixed adoption is first-class", + "body": "A project can retain modules for component styles, audit their emitted CSS, and introduce typed identities only for new or shared surfaces.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/tooling/cli.md", + "sourceSha256": "sha256:e123d55383efc625fef6e59f9c9252514abf3277ba1ec8bf5aa72f7113b61509", + "route": "/docs/tooling/cli/", + "title": "One CLI, explicit modes.", + "category": "Tooling", + "eyebrow": "Command-line reference", + "summary": "Compile, inspect, audit, transform, explain, bundle, watch, migrate, format, plan, and verify with strict argument contracts.", + "order": 22, + "sections": [ + { + "id": "discover", + "title": "Discover the real command surface", + "body": "Root and command help are generated from the maintained command synopsis. Unknown options, duplicate single-value options, and missing values fail.", + "code": "pliego-cssc --help\npliego-cssc compile --help" + }, + { + "id": "diagnostics", + "title": "Select diagnostic transport globally", + "body": "Human and JSON diagnostics can be requested before or after the command without stealing option values that happen to resemble flags.", + "code": "pliego-cssc --diagnostic-format json check --style \"p-4 p-6\" --seed" + }, + { + "id": "determinism", + "title": "Use --check in CI", + "body": "Commands that publish controlled or generated artifacts expose a read-only comparison path. CI should verify the checked-in or staged output, not regenerate it silently.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/tooling/lsp.md", + "sourceSha256": "sha256:22dbe9698cf617c210186f2edab5f0a6d2dcb061ffce39e6bac1a944743bda5e", + "route": "/docs/tooling/lsp/", + "title": "Compiler feedback without editor subprocesses.", + "category": "Tooling", + "eyebrow": "Language server", + "summary": "The LSP uses the same in-process `AnalysisHost` as CLI and watch without launching `pliego-cssc` or writing synthetic source files.", + "order": 23, + "sections": [ + { + "id": "features", + "title": "Diagnostics, completion, and hover", + "body": "Visible `pc!` and `pcx!` literals receive shared diagnostics, catalog-backed completion, and compiler-emitted hover CSS under the exact active theme.", + "code": null + }, + { + "id": "cancellation", + "title": "Suppress stale document work", + "body": "Document versions are debounced for 150 ms, and version checks around bounded in-process analysis prevent obsolete results from being published.", + "code": null + }, + { + "id": "limits", + "title": "Bound analysis", + "body": "Each document is capped at 256 semantic checks, the shared cache is bounded and theme-invalidated, and excessive input emits `PCL002`.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/tooling/watch.md", + "sourceSha256": "sha256:3aabe8443539de2801d0270c155a0d47bea439203f73ce3fcbc5166765158cf2", + "route": "/docs/tooling/watch/", + "title": "Keep the last complete truth.", + "category": "Tooling", + "eyebrow": "Watch mode", + "summary": "Poll coherent snapshots, republish only valid groups, and retain the last valid output across transient edits.", + "order": 24, + "sections": [ + { + "id": "run", + "title": "Watch inputs and output", + "body": "The watcher accepts the same source, theme, target, format, manifest, reachability, and controlled-output options as compile.", + "code": "pliego-cssc watch --source src --theme \\\n --output dist/app.css --manifest dist/app.manifest.json" + }, + { + "id": "snapshot", + "title": "Confirm one coherent snapshot", + "body": "Inputs are observed twice before compilation so an editor save sequence does not publish a mixture of old and new files.", + "code": null + }, + { + "id": "last-valid", + "title": "Never replace validity with a partial edit", + "body": "A parser error, invalid token document, or failed group publication leaves the previous complete artifact set in place.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/tooling/repair.md", + "sourceSha256": "sha256:e948e434445108c4360c04904234d048339e2153d149ec3964e5def5f2d7d5cf", + "route": "/docs/tooling/repair/", + "title": "Plan first. Authorize exact bytes.", + "category": "Tooling", + "eyebrow": "Repair agent", + "summary": "Generate bounded repair proposals, bind authorization to source hashes, verify in staging, then publish receipts.", + "order": 25, + "sections": [ + { + "id": "plan", + "title": "Join findings to a proposal", + "body": "Repair plans name exact finding codes, files, byte ranges, replacements, expected hashes, and verification commands.", + "code": "pliego-cssc plan --findings findings.json \\\n --proposal proposal.json --source-root ." + }, + { + "id": "dry-run", + "title": "Verify before mutation", + "body": "Dry-run applies the authorized patch inside a staging copy and executes bounded Rust or browser checks without replacing the source tree.", + "code": "pliego-cssc fix --plan plan.json --findings findings.json \\\n --source-root . --dry-run" + }, + { + "id": "resource", + "title": "Time and output are part of safety", + "body": "Test, browser, toolchain, and version subprocesses use explicit deadlines and output limits while the repair lock is held.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/integrations/plain-html.md", + "sourceSha256": "sha256:9d84a07aad9047d542211be34d2f33f0b6a63d5dbe3358b7b817a338b6c4baf4", + "route": "/docs/integrations/plain-html/", + "title": "Audit and transform without a framework.", + "category": "Integrations", + "eyebrow": "Plain HTML", + "summary": "Use PliegoCSS as a standards control layer around an ordinary stylesheet and static HTML build.", + "order": 26, + "sections": [ + { + "id": "audit", + "title": "Start with emitted CSS", + "body": "No adapter is required. Audit the exact stylesheet the browser loads and archive canonical findings beside the build.", + "code": "pliego-cssc audit --input public/app.css --targets baseline-widely" + }, + { + "id": "transform", + "title": "Transform explicitly", + "body": "Compatibility transformation and minification are separate from analysis so a read-only audit never rewrites authored output.", + "code": "pliego-cssc transform-css --input src/app.css \\\n --output public/app.css --targets modern --format minified" + }, + { + "id": "typed", + "title": "Typed authoring is optional", + "body": "Rust applications can compile typed styles into the same static site, but plain HTML consumers only need the final CSS asset.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/integrations/build-tools.md", + "sourceSha256": "sha256:3b684781f55390e9e981032107b208eb12470e0c789ca8a97d35419d862d0d60", + "route": "/docs/integrations/build-tools/", + "title": "Integrate through files, not hidden hooks.", + "category": "Integrations", + "eyebrow": "Generic build tools", + "summary": "Vite, webpack, esbuild, CI systems, and custom pipelines consume explicit CSS, manifests, findings, maps, and asset plans.", + "order": 27, + "sections": [ + { + "id": "producer", + "title": "Run PliegoCSS as a producer", + "body": "Invoke compile or transform before the application bundler and pass its ordinary CSS output through the normal asset graph.", + "code": "\"scripts\": {\n \"css\": \"pliego-cssc compile --source src --output dist/pliego.css\",\n \"build\": \"npm run css && vite build\"\n}" + }, + { + "id": "consumer", + "title": "Consume stable sidecars", + "body": "Use the numbered JSON schemas for manifests, Token Graphs, findings, reachability, project indexes, and Asset Plans instead of scraping terminal text.", + "code": null + }, + { + "id": "ownership", + "title": "Keep topology in its owner", + "body": "A framework adapter may export route and component reachability. PliegoCSS validates and projects that sidecar but does not crawl application internals.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/reference/configuration.md", + "sourceSha256": "sha256:005f4656fdba04c614e19deb8addbe7628b287f2a609f11c8e01e7b1b73493ab", + "route": "/docs/reference/configuration/", + "title": "Configuration is part of identity.", + "category": "Reference", + "eyebrow": "Configuration", + "summary": "Theme, target, format, manifests, reachability, pruning, and token selections are explicit, validated, and hash-bound.", + "order": 28, + "sections": [ + { + "id": "discovery", + "title": "Conventional TOML discovery only", + "body": "Without --config, --tokens, or --seed, the CLI searches for pliego.theme.toml from the workspace and input package roots. Ambiguous candidates fail.", + "code": null + }, + { + "id": "exclusive", + "title": "Selectors are mutually exclusive", + "body": "--config, --tokens, and --seed cannot be combined. JSON token documents are never discovered implicitly.", + "code": null + }, + { + "id": "hash", + "title": "Bind the complete choice", + "body": "Targets, format, emission settings, manifest version, pruning, theme bytes, resolver selections, and reachability bytes participate in the control configuration hash.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/reference/diagnostics.md", + "sourceSha256": "sha256:25661884b0cc22c67c23e9e53ed8ac92faf156e460356a9a20d6583148342c95", + "route": "/docs/reference/diagnostics/", + "title": "Stable codes, precise ranges.", + "category": "Reference", + "eyebrow": "Diagnostics", + "summary": "Parser, source scanner, composition, policy, invocation, migration, and formatter failures share a canonical transport.", + "order": 29, + "sections": [ + { + "id": "human", + "title": "Human diagnostics", + "body": "Terminal output favors the next corrective action while retaining the stable code, severity, file, line, column, and byte range.", + "code": null + }, + { + "id": "json", + "title": "Canonical JSON", + "body": "JSON mode writes one schema-1 document to stderr on failure and leaves stdout empty. Consumers should branch on codes, not localized prose.", + "code": "pliego-cssc --diagnostic-format json check --style \"p-4 p-6\" --seed" + }, + { + "id": "sarif", + "title": "SARIF for code scanning", + "body": "Audit findings can be serialized as SARIF while preserving canonical rule identity and source regions for hosted review systems.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/release-readiness.md", + "sourceSha256": "sha256:c76cf2b2f691ede1b58d55c2ef9480c6a58b718e80d7d069074ff7222a968d29", + "route": "/docs/release-readiness/", + "title": "Release truth bound to one exact candidate.", + "category": "Evidence", + "eyebrow": "Release readiness", + "summary": "The current 1.0 promotion state is blocked; no historical report can override these source-bound gates.", + "order": 30, + "sections": [ + { + "id": "candidate", + "title": "Candidate identity", + "body": "Target 0.1.0; candidate 0.1.0-rc.2; tag v0.1.0-rc.2; commit 064dcbce96a3a5cc97a940d07566c008bb5e2d3e; Git tree 010a64f5d0bbd142a1d015fda4ccf573cc6e4133; observed 2026-07-22T14:10:00Z; expires 2026-07-29T14:10:00Z.", + "code": null + }, + { + "id": "dimensions", + "title": "Readiness dimensions", + "body": "Technical: blocked; Operational: blocked; Adoption: blocked; Authorized: blocked; Promotion: blocked.", + "code": null + }, + { + "id": "blockers", + "title": "Current blockers", + "body": "exact-candidate-ci (pending): GitHub Actions run 29864046865 passed the exact RC.2 commit across Ubuntu, Windows, and macOS with Rust 1.85 and 1.96 plus the listed release jobs, but its promotion window has expired and requires a fresh candidate replay. exact-candidate-codeql (pending): CodeQL run 29864046876 passed Actions, JavaScript/TypeScript, and Rust analysis on the exact RC.2 commit, but its promotion window has expired and requires a fresh candidate replay. g0-corrections-on-candidate (pending): G0 corrections are not part of the exact RC.2 candidate. They require a newly named candidate plus hosted replay before they can become release evidence. browser-release-matrix (blocked): Every required hosted browser lane is not configured for the exact candidate source; local or historical browser runs have no promotion authority. adapter-coexistence-matrix (blocked): RC.2 predates the G6 adapter authority. It has no clean exact-source 21-project HTML/Vite/PliegoRS matrix, Tailwind v3/v4 source/output audit, DOM/ARIA/style equivalence, exact rollback, or pinned PliegoRS framework replay. registry-replay-refresh (pending): RC.2 was previously replayed from crates.io, but schema 3 requires a fresh, unexpired, artifact-hashed replay for the exact promotion candidate. production-deployment-refresh (pending): The production site has historical deployment evidence, but a current exact-source edge and browser replay with hashed artifacts is missing. signed-binary-distribution (blocked): RC.2 predates the repository-only G5 distribution contract. It has no exact-source native archives, checksums, CycloneDX SBOMs, GitHub/Sigstore attestations, immutable GitHub Release, or verified pnpm package asset; npmjs publication is forbidden. external-adoption-evidence (pending): RC.2 predates the G7 authority. Recruitment is open, but the required external interviews, real incidents, and pilots have not been recorded as consented, redacted, reviewed, hash-bound evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree.", + "code": null + } + ] + }, + { + "sourcePath": "docs/site/external-adoption.md", + "sourceSha256": "sha256:48066f009e353b7b7a4983d82d9ac1a518d430260be4d824128f1f7d5af4e408", + "route": "/docs/external-adoption/", + "title": "External adoption is a measured gate.", + "category": "Evidence", + "eyebrow": "G7 adoption", + "summary": "G7 is currently blocked. Owned fixtures and synthetic incidents cannot satisfy it.", + "order": 31, + "sections": [ + { + "id": "status", + "title": "Qualified evidence", + "body": "Interviews: 0/10. Real incidents: 0/20. Completed pilots: 0/3. Independent projects: 0/3. Independent organizations: 0/3.", + "code": null + }, + { + "id": "apply", + "title": "Applications are open", + "body": "Read the [public cohort notice](https://github.com/celiumsai/pliegocss/issues/7) or apply privately at [hello@pliegocss.dev](mailto:hello@pliegocss.dev?subject=G7%20external%20adoption). Do not post names, email addresses, credentials, private repository URLs, proprietary source, transcripts, or incident material on GitHub. A public application is not consent and is not evidence.", + "code": null + }, + { + "id": "boundary", + "title": "Admission boundary", + "body": "Only consented, redacted, reviewed external records bound to private source evidence by SHA-256 count. Projects owned by Celiums Solutions, PliegoCSS, or celiumsai do not count.", + "code": null + }, + { + "id": "support", + "title": "Certified adapter scope", + "body": "The frozen 0.1.x policy covers complete literal class groups in static HTML, Vite 8.1.5, and pinned PliegoRS rendered output against Tailwind CSS 3.4.19 or 4.3.3. Tailwind stays loaded during coexistence. Dynamic classes, arbitrary config/plugin execution, and Tailwind removal are excluded.", + "code": null + } + ] + } + ] +} diff --git a/site/src/pages.rs b/site/src/pages.rs index a20ad0b..b459fad 100644 --- a/site/src/pages.rs +++ b/site/src/pages.rs @@ -3,6 +3,7 @@ use pliego_css::{Style, pc}; use pliego_dom::{Element as DomElement, IntoView, View, el as dom_el}; use pliego_ssg::{Head, Page}; +use serde::Deserialize; use serde_json::json; use std::cell::Cell; use std::collections::BTreeMap; @@ -167,853 +168,54 @@ const ACTION_STYLE: Style = pc!( const PANEL_STYLE: Style = pc!("rounded-xl border border-line bg-surface p-6 shadow-md"); const LABEL_STYLE: Style = pc!("text-xs font-semibold text-muted"); -struct DocPage { - route: &'static str, - title: &'static str, - category: &'static str, - eyebrow: &'static str, - summary: &'static str, - sections: &'static [DocSection], +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocManifest { + schema_version: u64, + kind: String, + documents: Vec, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocPage { + source_path: String, + source_sha256: String, + route: String, + title: String, + category: String, + eyebrow: String, + summary: String, + order: usize, + sections: Vec, +} + +#[derive(Deserialize)] struct DocSection { - id: &'static str, - title: &'static str, - body: &'static str, - code: Option<&'static str>, -} - -const DOCS: &[DocPage] = &[ - DocPage { - route: "/docs/getting-started/", - title: "Start with the CSS you already have.", - category: "Start", - eyebrow: "Getting started", - summary: "Audit first, compile when it adds value, and keep the browser contract ordinary.", - sections: &[ - DocSection { - id: "install", - title: "Install the release candidate", - body: "The complete 0.1.0-rc.2 compatibility unit is published on crates.io and replayed from the registry with Rust 1.85.", - code: Some( - "cargo install pliego-cssc --version '=0.1.0-rc.2' --locked", - ), - }, - DocSection { - id: "audit", - title: "Audit ordinary CSS", - body: "Start without changing authoring. The analyzer emits human, canonical JSON, or SARIF findings and fails closed on unknown surfaces.", - code: Some( - "pliego-cssc audit --input app.css \\\n --targets baseline-widely --format human", - ), - }, - DocSection { - id: "compile", - title: "Add typed identities selectively", - body: "Rust literals are validated at compile time and compile to static standards-compliant CSS. There is no styling runtime.", - code: Some( - "use pliego_css::{pc, Style};\n\nconst BUTTON: Style = pc!(\n \"inline-flex gap-2 rounded-lg bg-accent px-4 py-2\"\n);", - ), - }, - ], - }, - DocPage { - route: "/docs/typed-styles/", - title: "Types at author time. CSS at run time.", - category: "Core", - eyebrow: "Typed styles", - summary: "Stable 128-bit identities connect Rust source, emitted classes, manifests, and physical declarations.", - sections: &[ - DocSection { - id: "pc", - title: "One literal, one stable identity", - body: "pc! accepts one visible string literal. The macro parses semantics, rejects conflicting slots, and returns a sealed Style.", - code: Some( - "const CARD: Style = pc!(\n \"grid gap-4 rounded-xl border border-line p-6 shadow-md\"\n);", - ), - }, - DocSection { - id: "pcx", - title: "Visible state spaces", - body: "pcx! compiles every visible branch combination and selects one identity at runtime. Independent clauses that can collide are rejected.", - code: Some( - "let style = pcx!(\n \"block\",\n if active { \"opacity-100\" } else { \"opacity-50\" }\n);", - ), - }, - DocSection { - id: "lineage", - title: "Follow the physical effect", - body: "Manifest schema 5 joins semantic declarations to final rules, conditions, byte ranges, and synthesized effects such as box-shadow.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/audit-and-transform/", - title: "Standards-first, not framework-first.", - category: "Standard CSS", - eyebrow: "Audit and transform", - summary: "Analyze standard CSS, enforce policy, and transform output without claiming ownership of authored source.", - sections: &[ - DocSection { - id: "classifier", - title: "Bounded classification", - body: "The classifier recognizes deliberate standard-CSS surfaces and reports unsupported constructs instead of guessing. A versioned corpus controls the claim.", - code: None, - }, - DocSection { - id: "transform", - title: "Deterministic transformation", - body: "Target browsers, minify, and check output drift. Add --check in CI to remain read-only.", - code: Some( - "pliego-cssc transform-css --input app.css \\\n --output dist/app.css --targets modern --format minified", - ), - }, - DocSection { - id: "policy", - title: "Compatibility, accessibility, and budgets", - body: "Policy documents bind findings to explicit targets and limits. The same canonical findings feed terminals, JSON automation, and SARIF.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/evidence/", - title: "A green build should explain itself.", - category: "Evidence", - eyebrow: "Evidence and provenance", - summary: "Artifacts carry hashes, source lineage, graphs, findings, and receipts designed for replay.", - sections: &[ - DocSection { - id: "manifest", - title: "Canonical manifests", - body: "Style identity, class identity, theme identity, origins, conditions, physical declarations, and final CSS hashes live in a deterministic document.", - code: Some( - "pliego-cssc compile --source src --seed --theme \\\n --output dist/app.css --manifest dist/app.manifest.json", - ), - }, - DocSection { - id: "receipts", - title: "Receipt-last publication", - body: "Controlled operations stage output, lock the destination, verify inputs, publish artifacts, and write the receipt last.", - code: None, - }, - DocSection { - id: "benchmarks", - title: "Measured, inherited, pending, uncertain", - body: "Release readiness preserves the evidence class. Old hosted evidence never silently passes changed source.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/migration/", - title: "Adopt without surrendering the exit.", - category: "Migration", - eyebrow: "Controlled migration", - summary: "Inventory first, generate bounded plans, preserve originals, and detect drift immediately before publication.", - sections: &[ - DocSection { - id: "inventory", - title: "Read-only discovery", - body: "Tailwind, Sass, CSS Modules, and standard CSS can be inventoried before any replacement plan exists.", - code: Some("pliego-cssc migration-project-plan ./migration-declaration.json"), - }, - DocSection { - id: "reversible", - title: "Reversible replacements", - body: "Plans bind source and destination hashes. Apply and rollback use adjacent locks, pre-publication revalidation, backups, and group compensation.", - code: None, - }, - DocSection { - id: "limits", - title: "No speculative codemods", - body: "Unsupported or context-dependent constructs stay in the inventory. The migration layer does not infer framework semantics it cannot prove.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/integrations/pliegors/", - title: "Native together. Independent by contract.", - category: "Integrations", - eyebrow: "PliegoRS integration", - summary: "PliegoRS is the first native consumer, not the only target. It supplies topology; PliegoCSS supplies style identity and artifacts.", - sections: &[ - DocSection { - id: "boundary", - title: "Explicit ownership", - body: "PliegoRS owns routes, pages, assets, and browser lifecycle. PliegoCSS owns parsing, semantic conflicts, CSS, manifests, and policy findings.", - code: None, - }, - DocSection { - id: "bundles", - title: "Route and island bundles", - body: "A reachability sidecar projects application-owned topology into selected CSS bundles without PliegoCSS pretending to understand the framework.", - code: None, - }, - DocSection { - id: "proof", - title: "Replayed from published crates", - body: "The maintained fixture locks PliegoRS 0.0.2 registry packages and verifies the remote-reachable framework revision separately.", - code: Some("PLIEGORS_ROOT=/path/to/pliegors pnpm integration:pliegors"), - }, - ], - }, - DocPage { - route: "/docs/installation/", - title: "Choose an adoption surface.", - category: "Start", - eyebrow: "Installation", - summary: "Install the CLI for any project, add the Rust crates only when typed authoring belongs in the application.", - sections: &[ - DocSection { - id: "cli", - title: "CLI-only adoption", - body: "The command-line compiler can audit, transform, explain, bundle, and migrate standard CSS without adding a runtime dependency to the application.", - code: Some( - "cargo install pliego-cssc --version '=0.1.0-rc.2' --locked\npliego-cssc --version", - ), - }, - DocSection { - id: "rust", - title: "Typed Rust authoring", - body: "Add pliego-css and its macro crate to applications that want compile-time style identities. The emitted result remains ordinary CSS.", - code: Some( - "[dependencies]\npliego-css = \"=0.1.0-rc.2\"", - ), - }, - DocSection { - id: "pin", - title: "Pin the exact release identity", - body: "Before the public release, use the signed project tag plus Cargo.lock. Do not track a moving branch in a production build.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/first-audit/", - title: "Learn before changing.", - category: "Start", - eyebrow: "First audit", - summary: "Run a read-only CSS audit, inspect canonical findings, then decide whether transformation or policy is justified.", - sections: &[ - DocSection { - id: "human", - title: "Read the first report", - body: "Human output is optimized for local diagnosis. Every finding carries a stable code and source location rather than an opaque score.", - code: Some( - "pliego-cssc audit --input app.css \\\n --targets baseline-widely --format human", - ), - }, - DocSection { - id: "automation", - title: "Feed automation safely", - body: "Canonical JSON and SARIF preserve the same finding model for CI, code scanning, and archival evidence.", - code: Some( - "pliego-cssc audit --input app.css --targets modern --format sarif > findings.sarif", - ), - }, - DocSection { - id: "unknown", - title: "Unknown means unknown", - body: "The standard-CSS classifier is bounded. Unsupported selectors, properties, or values fail closed under compatibility policy rather than being treated as safe.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/editor-setup/", - title: "Put the compiler in the feedback loop.", - category: "Start", - eyebrow: "Editor setup", - summary: "Use the language server for diagnostics, completion, hover evidence, and catalog-aware authoring.", - sections: &[ - DocSection { - id: "vscode", - title: "VS Code extension", - body: "The maintained extension launches pliego-css-lsp, discovers the project catalog, and renders diagnostics in Rust source.", - code: Some("pnpm --filter pliegocss-vscode package"), - }, - DocSection { - id: "generic", - title: "Any LSP client", - body: "Launch the server over stdio and point it at the same compiler and workspace used by CI. The protocol stays editor-neutral.", - code: Some("pliego-css-lsp --stdio"), - }, - DocSection { - id: "safety", - title: "Bound external tools", - body: "Compiler subprocesses have explicit deadlines and output caps. A stalled custom executable must not freeze the editor forever.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/syntax/", - title: "A finite language with visible edges.", - category: "Core", - eyebrow: "Utility syntax", - summary: "Utilities lower into semantic slots, conditions, and typed values before any CSS serialization occurs.", - sections: &[ - DocSection { - id: "shape", - title: "Utility plus typed value", - body: "Spacing, color, typography, layout, effects, and interactivity draw from one validated theme registry. Arbitrary escape hatches remain policy-visible.", - code: Some("flex items-center gap-4 rounded-lg bg-surface px-4 py-2 text-sm"), - }, - DocSection { - id: "conditions", - title: "Conditions are structure", - body: "Responsive, state, attribute, container, layer, and media variants become normalized condition paths. Their order is not incidental string decoration.", - code: Some("md:grid-cols-2 hover:bg-accent-strong aria-[expanded=true]:ring-2"), - }, - DocSection { - id: "important", - title: "Importance follows the physical effect", - body: "A trailing exclamation mark marks the semantic contribution important. Synthesized effects such as the composed box-shadow preserve that authority in final CSS and lineage.", - code: Some("ring-2! shadow-md!"), - }, - ], - }, - DocPage { - route: "/docs/composition/", - title: "Compose by meaning, not source order.", - category: "Core", - eyebrow: "Composition", - summary: "Compatible slots commute. Conflicting slots require an explicit branch-over-base operation.", - sections: &[ - DocSection { - id: "commute", - title: "Equivalent input, equivalent identity", - body: "Utilities that occupy independent semantic slots canonicalize to the same identity even when authored in a different order.", - code: Some("flex gap-4 p-6\np-6 flex gap-4"), - }, - DocSection { - id: "conflict", - title: "Ambiguous lists are rejected", - body: "p-4 p-6 does not mean last one wins. Both declarations claim the same slot under the same condition, so compilation stops.", - code: Some("pliego-cssc check --style \"p-4 p-6\" --seed"), - }, - DocSection { - id: "override", - title: "Overrides are explicit operations", - body: "When an application intentionally replaces one semantic assignment with another, use composition so the override relationship remains visible and inspectable.", - code: Some("pliego-cssc compile --compose \"p-4\" \"p-6\" --seed"), - }, - ], - }, - DocPage { - route: "/docs/variants/", - title: "Conditions without string-order folklore.", - category: "Core", - eyebrow: "Variants and conditions", - summary: "Every variant joins a typed condition path with deterministic normalization and specificity behavior.", - sections: &[ - DocSection { - id: "responsive", - title: "Responsive paths", - body: "Theme breakpoints are validated once and emitted as ordered media conditions. Assignments at different breakpoints do not conflict.", - code: Some("grid-cols-1 md:grid-cols-2 lg:grid-cols-3"), - }, - DocSection { - id: "state", - title: "State and attributes", - body: "Pseudo-class and bounded attribute variants stay in semantic IR so policy and physical lineage can explain their selectors.", - code: Some("hover:bg-accent focus-visible:ring-2 data-[state=open]:opacity-100"), - }, - DocSection { - id: "layers", - title: "Cascade layers", - body: "Fixed layer variants model author-controlled precedence without allowing arbitrary source-order dependence to leak into identity.", - code: Some("layer-components:bg-surface layer-utilities:p-4"), - }, - ], - }, - DocPage { - route: "/docs/themes/", - title: "Resolve tokens before styles.", - category: "Core", - eyebrow: "Themes and tokens", - summary: "TOML themes and DTCG Resolver documents feed the same typed registry, identity, graph, and emitted variables.", - sections: &[ - DocSection { - id: "toml", - title: "Extend the seed", - body: "A project theme can override or add typed colors, spacing, radii, typography, and breakpoints without changing utility grammar.", - code: Some( - "schema = 1\nextends = \"seed\"\n\n[tokens.color]\nbrand = \"oklch(62% 0.18 250)\"", - ), - }, - DocSection { - id: "dtcg", - title: "Select DTCG contexts explicitly", - body: "Resolver inputs are never auto-discovered. Modifier selections participate in configuration identity even when two contexts resolve to equal values.", - code: Some( - "pliego-cssc compile --tokens product.resolver.json \\\n --token-input appearance=dark --style \"bg-brand\"", - ), - }, - DocSection { - id: "graph", - title: "Publish the complete Token Graph", - body: "Controlled output binds selected CSS to canonical token provenance, aliases, permutations, and reachability-aware usage evidence.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/standard-css/policies/", - title: "Make browser support and budgets executable.", - category: "Standard CSS", - eyebrow: "Policies", - summary: "Compatibility, accessibility, payload, package, route, and ownership policies turn expectations into reviewable inputs.", - sections: &[ - DocSection { - id: "compatibility", - title: "Version the target", - body: "baseline-widely and modern are frozen policy profiles with explicit browser and source-data identities. They do not drift silently with a dependency update.", - code: Some("pliego-cssc compatibility --targets baseline-widely"), - }, - DocSection { - id: "budgets", - title: "Bind budgets to owners", - body: "Direct files can name budget subjects manually. Asset Plans use an ownership sidecar to aggregate package and composed-route costs without guessing responsibility.", - code: Some( - "pliego-cssc audit --asset-plan dist/pliego.assets.json \\\n --ownership pliego.ownership.json --budget-policy budgets.json", - ), - }, - DocSection { - id: "accessibility", - title: "Audit static accessibility contracts", - body: "Contrast and interaction guards use explicit policy plus token evidence. Dynamic visual behavior still requires browser and assistive-technology testing.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/cascade/", - title: "Explain the winning declaration.", - category: "Standard CSS", - eyebrow: "Cascade inspection", - summary: "Inspect authored CSS by element and longhand to see matching selectors, precedence, and the final winner.", - sections: &[ - DocSection { - id: "query", - title: "Ask one precise question", - body: "Provide a CSS file, a bounded element description, and one longhand. The command reports candidates instead of simulating an entire browser.", - code: Some( - "pliego-cssc explain-cascade --input app.css \\\n --element 'button#save.action' --property background-color", - ), - }, - DocSection { - id: "winner", - title: "See why it won", - body: "Origin, importance, layer, specificity, and source position are surfaced in comparison order with a stable machine-readable form.", - code: None, - }, - DocSection { - id: "boundary", - title: "Keep the model honest", - body: "This is a static bounded inspector. Runtime DOM state, animation interpolation, adopted sheets, and script mutation remain browser responsibilities.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/manifests/", - title: "Trace source intent into emitted bytes.", - category: "Evidence", - eyebrow: "Style manifests", - summary: "Manifest schema 5 connects source origins, semantic assignments, final selectors, physical declarations, and CSS ranges.", - sections: &[ - DocSection { - id: "identity", - title: "Identity chain", - body: "StyleId identifies canonical semantics. Class identity, theme identity, configuration, and final CSS hash bind that style into one build.", - code: Some( - "pliego-cssc compile --source src --manifest dist/app.manifest.json \\\n --manifest-version 5 --output dist/app.css", - ), - }, - DocSection { - id: "physical", - title: "Physical lineage", - body: "Generated custom properties and synthesized declarations retain contributors, importance, conditions, selector, property, value, and output byte range.", - code: None, - }, - DocSection { - id: "versions", - title: "Request the schema you consume", - body: "Schema 3 supports style manifests, schema 4 adds reachability ownership, and schema 5 adds closed physical tracing. Consumers must pin their contract.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/control-artifacts/", - title: "Publish the receipt last.", - category: "Evidence", - eyebrow: "Control artifacts", - summary: "Controlled builds stage related outputs, lock the destination, verify hashes, compensate failures, and expose one completed receipt.", - sections: &[ - DocSection { - id: "group", - title: "One rollback-capable group", - body: "CSS, source map, style manifest, Token Graph, findings, control manifest, and receipt are generated from one exact input snapshot.", - code: Some( - "pliego-cssc compile --source src --output dist/app.css \\\n --manifest dist/app.manifest.json --control-dir dist", - ), - }, - DocSection { - id: "check", - title: "Check without writing", - body: "The same command with --check recomputes every artifact and fails on byte drift without taking publication locks or replacing files.", - code: Some( - "pliego-cssc compile --source src --output dist/app.css \\\n --manifest dist/app.manifest.json --control-dir dist --check", - ), - }, - DocSection { - id: "receipt", - title: "Completion is explicit", - body: "Consumers treat the fixed receipt as the publication boundary. Its absence means the group is incomplete, even if individual files are present.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/critical-css/", - title: "Prove what rendered above the fold.", - category: "Evidence", - eyebrow: "Critical style evidence", - summary: "Join observed browser behavior to project topology and retained style identity without letting an observation silently authorize removal.", - sections: &[ - DocSection { - id: "observe", - title: "Observe a real route", - body: "Capture viewport, route, interaction state, loaded assets, matched styles, and source identities from a browser run against the built application.", - code: None, - }, - DocSection { - id: "join", - title: "Join exact universes", - body: "Evidence is accepted only when its project index, asset plan, reachability, and CSS identities match the build under review.", - code: None, - }, - DocSection { - id: "authority", - title: "Observation does not equal deletion", - body: "A style unseen in one scope remains unknown outside that scope. Explicit retention and reachability policies control removal authority.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/migration/tailwind/", - title: "Inventory Tailwind before translating it.", - category: "Migration", - eyebrow: "Tailwind migration", - summary: "Recover the real utility surface, classify supported roles, and keep unsupported framework behavior visible.", - sections: &[ - DocSection { - id: "inventory", - title: "Scan without replacement", - body: "The inventory recognizes bounded class-bearing surfaces and reports candidates with source ranges. It does not rewrite template semantics during discovery.", - code: Some("pliego-cssc migration-inventory tailwind src/App.tsx"), - }, - DocSection { - id: "plan", - title: "Generate a hash-bound project plan", - body: "A declaration selects roots and destinations. The plan records exact source and output hashes before any replacement can be applied.", - code: Some("pliego-cssc migration-project-plan migration.json"), - }, - DocSection { - id: "gaps", - title: "Preserve unsupported behavior", - body: "Plugins, generated variants, arbitrary selectors, and runtime class construction remain explicit migration findings unless a project-owned adapter proves them.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/migration/sass/", - title: "Separate syntax conversion from semantic adoption.", - category: "Migration", - eyebrow: "Sass migration", - summary: "Inventory variables, nesting, mixins, imports, and generated surfaces without pretending every Sass abstraction is a utility.", - sections: &[ - DocSection { - id: "inventory", - title: "Map the source surface", - body: "The inventory records statically visible Sass roles and unresolved dynamic behavior so the team can choose what stays authored CSS.", - code: Some("pliego-cssc migration-inventory sass styles/main.scss"), - }, - DocSection { - id: "css-first", - title: "Ordinary CSS is a valid destination", - body: "Use the standard-CSS transformer for syntax and compatibility work. Adopt typed PliegoCSS styles only where identity and provenance pay for themselves.", - code: None, - }, - DocSection { - id: "rollback", - title: "Keep originals and rollback evidence", - body: "Applied replacements preserve source backups and hash-bound rollback records. A changed destination aborts rather than overwriting concurrent edits.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/migration/css-modules/", - title: "Preserve component ownership.", - category: "Migration", - eyebrow: "CSS Modules migration", - summary: "Discover module exports and usage while keeping application-owned component topology separate from CSS semantics.", - sections: &[ - DocSection { - id: "inventory", - title: "Inventory exported classes", - body: "The scanner identifies bounded module class definitions and their source locations without assuming a particular bundler naming strategy.", - code: Some("pliego-cssc migration-inventory css-modules src/Card.module.css"), - }, - DocSection { - id: "ownership", - title: "Let the application own reachability", - body: "Route and component reachability comes from the framework or product index, not from filename heuristics inside the CSS compiler.", - code: None, - }, - DocSection { - id: "mixed", - title: "Mixed adoption is first-class", - body: "A project can retain modules for component styles, audit their emitted CSS, and introduce typed identities only for new or shared surfaces.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/tooling/cli/", - title: "One CLI, explicit modes.", - category: "Tooling", - eyebrow: "Command-line reference", - summary: "Compile, inspect, audit, transform, explain, bundle, watch, migrate, format, plan, and verify with strict argument contracts.", - sections: &[ - DocSection { - id: "discover", - title: "Discover the real command surface", - body: "Root and command help are generated from the maintained command synopsis. Unknown options, duplicate single-value options, and missing values fail.", - code: Some("pliego-cssc --help\npliego-cssc compile --help"), - }, - DocSection { - id: "diagnostics", - title: "Select diagnostic transport globally", - body: "Human and JSON diagnostics can be requested before or after the command without stealing option values that happen to resemble flags.", - code: Some("pliego-cssc --diagnostic-format json check --style \"p-4 p-6\" --seed"), - }, - DocSection { - id: "determinism", - title: "Use --check in CI", - body: "Commands that publish controlled or generated artifacts expose a read-only comparison path. CI should verify the checked-in or staged output, not regenerate it silently.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/tooling/lsp/", - title: "Compiler feedback without editor lockups.", - category: "Tooling", - eyebrow: "Language server", - summary: "The LSP shares parser and catalog contracts with the CLI while bounding subprocess time and output.", - sections: &[ - DocSection { - id: "features", - title: "Diagnostics, completion, and hover", - body: "Visible pc! and pcx! literals receive parser diagnostics, catalog-backed completion, and inspectable utility information.", - code: None, - }, - DocSection { - id: "cancellation", - title: "Cancel stale document work", - body: "Document version changes cancel obsolete checks. Independent process deadlines prevent an unchanged document from waiting forever on a stalled compiler.", - code: None, - }, - DocSection { - id: "limits", - title: "Bound untrusted output", - body: "stdout and stderr are read under explicit caps. Oversized or timed-out compiler responses become diagnostics instead of unbounded memory growth.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/tooling/watch/", - title: "Keep the last complete truth.", - category: "Tooling", - eyebrow: "Watch mode", - summary: "Poll coherent snapshots, republish only valid groups, and retain the last valid output across transient edits.", - sections: &[ - DocSection { - id: "run", - title: "Watch inputs and output", - body: "The watcher accepts the same source, theme, target, format, manifest, reachability, and controlled-output options as compile.", - code: Some( - "pliego-cssc watch --source src --theme \\\n --output dist/app.css --manifest dist/app.manifest.json", - ), - }, - DocSection { - id: "snapshot", - title: "Confirm one coherent snapshot", - body: "Inputs are observed twice before compilation so an editor save sequence does not publish a mixture of old and new files.", - code: None, - }, - DocSection { - id: "last-valid", - title: "Never replace validity with a partial edit", - body: "A parser error, invalid token document, or failed group publication leaves the previous complete artifact set in place.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/tooling/repair/", - title: "Plan first. Authorize exact bytes.", - category: "Tooling", - eyebrow: "Repair agent", - summary: "Generate bounded repair proposals, bind authorization to source hashes, verify in staging, then publish receipts.", - sections: &[ - DocSection { - id: "plan", - title: "Join findings to a proposal", - body: "Repair plans name exact finding codes, files, byte ranges, replacements, expected hashes, and verification commands.", - code: Some( - "pliego-cssc plan --findings findings.json \\\n --proposal proposal.json --source-root .", - ), - }, - DocSection { - id: "dry-run", - title: "Verify before mutation", - body: "Dry-run applies the authorized patch inside a staging copy and executes bounded Rust or browser checks without replacing the source tree.", - code: Some( - "pliego-cssc fix --plan plan.json --findings findings.json \\\n --source-root . --dry-run", - ), - }, - DocSection { - id: "resource", - title: "Time and output are part of safety", - body: "Test, browser, toolchain, and version subprocesses use explicit deadlines and output limits while the repair lock is held.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/integrations/plain-html/", - title: "Audit and transform without a framework.", - category: "Integrations", - eyebrow: "Plain HTML", - summary: "Use PliegoCSS as a standards control layer around an ordinary stylesheet and static HTML build.", - sections: &[ - DocSection { - id: "audit", - title: "Start with emitted CSS", - body: "No adapter is required. Audit the exact stylesheet the browser loads and archive canonical findings beside the build.", - code: Some("pliego-cssc audit --input public/app.css --targets baseline-widely"), - }, - DocSection { - id: "transform", - title: "Transform explicitly", - body: "Compatibility transformation and minification are separate from analysis so a read-only audit never rewrites authored output.", - code: Some( - "pliego-cssc transform-css --input src/app.css \\\n --output public/app.css --targets modern --format minified", - ), - }, - DocSection { - id: "typed", - title: "Typed authoring is optional", - body: "Rust applications can compile typed styles into the same static site, but plain HTML consumers only need the final CSS asset.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/integrations/build-tools/", - title: "Integrate through files, not hidden hooks.", - category: "Integrations", - eyebrow: "Generic build tools", - summary: "Vite, webpack, esbuild, CI systems, and custom pipelines consume explicit CSS, manifests, findings, maps, and asset plans.", - sections: &[ - DocSection { - id: "producer", - title: "Run PliegoCSS as a producer", - body: "Invoke compile or transform before the application bundler and pass its ordinary CSS output through the normal asset graph.", - code: Some( - "\"scripts\": {\n \"css\": \"pliego-cssc compile --source src --output dist/pliego.css\",\n \"build\": \"npm run css && vite build\"\n}", - ), - }, - DocSection { - id: "consumer", - title: "Consume stable sidecars", - body: "Use the numbered JSON schemas for manifests, Token Graphs, findings, reachability, project indexes, and Asset Plans instead of scraping terminal text.", - code: None, - }, - DocSection { - id: "ownership", - title: "Keep topology in its owner", - body: "A framework adapter may export route and component reachability. PliegoCSS validates and projects that sidecar but does not crawl application internals.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/reference/configuration/", - title: "Configuration is part of identity.", - category: "Reference", - eyebrow: "Configuration", - summary: "Theme, target, format, manifests, reachability, pruning, and token selections are explicit, validated, and hash-bound.", - sections: &[ - DocSection { - id: "discovery", - title: "Conventional TOML discovery only", - body: "Without --config, --tokens, or --seed, the CLI searches for pliego.theme.toml from the workspace and input package roots. Ambiguous candidates fail.", - code: None, - }, - DocSection { - id: "exclusive", - title: "Selectors are mutually exclusive", - body: "--config, --tokens, and --seed cannot be combined. JSON token documents are never discovered implicitly.", - code: None, - }, - DocSection { - id: "hash", - title: "Bind the complete choice", - body: "Targets, format, emission settings, manifest version, pruning, theme bytes, resolver selections, and reachability bytes participate in the control configuration hash.", - code: None, - }, - ], - }, - DocPage { - route: "/docs/reference/diagnostics/", - title: "Stable codes, precise ranges.", - category: "Reference", - eyebrow: "Diagnostics", - summary: "Parser, source scanner, composition, policy, invocation, migration, and formatter failures share a canonical transport.", - sections: &[ - DocSection { - id: "human", - title: "Human diagnostics", - body: "Terminal output favors the next corrective action while retaining the stable code, severity, file, line, column, and byte range.", - code: None, - }, - DocSection { - id: "json", - title: "Canonical JSON", - body: "JSON mode writes one schema-1 document to stderr on failure and leaves stdout empty. Consumers should branch on codes, not localized prose.", - code: Some("pliego-cssc --diagnostic-format json check --style \"p-4 p-6\" --seed"), - }, - DocSection { - id: "sarif", - title: "SARIF for code scanning", - body: "Audit findings can be serialized as SARIF while preserving canonical rule identity and source regions for hosted review systems.", - code: None, - }, - ], - }, -]; + id: String, + title: String, + body: String, + code: Option, +} + +fn docs() -> &'static [DocPage] { + static DOCS: OnceLock> = OnceLock::new(); + DOCS.get_or_init(|| { + let manifest: DocManifest = serde_json::from_str(include_str!("docs.generated.json")) + .expect("generated Markdown document manifest must parse"); + assert_eq!( + manifest.schema_version, 1, + "unsupported site document schema" + ); + assert_eq!(manifest.kind, "pliegocss-site-markdown"); + for (index, document) in manifest.documents.iter().enumerate() { + assert_eq!(document.order, index + 1, "site document order drifted"); + assert!(document.source_path.starts_with("docs/site/")); + assert!(document.source_sha256.starts_with("sha256:")); + } + manifest.documents + }) +} pub fn all() -> Vec { let mut pages = Vec::new(); @@ -1076,11 +278,14 @@ pub fn all() -> Vec { legal_document(slug), )); } - localized.extend( - DOCS.iter().map(|document| { - page(locale, document.route, document.title, doc_page(document)) - }), - ); + localized.extend(docs().iter().map(|document| { + page( + locale, + document.route.as_str(), + document.title.as_str(), + doc_page(document), + ) + })); localized })); } @@ -1427,7 +632,7 @@ fn home() -> View { .child( el("p") .class("hero-preview-label") - .child("PLIEGOCSS / 0.1.0-RC.2 / PUBLIC PREVIEW"), + .child("PLIEGOCSS / 0.1.0-RC.3 / CANDIDATE"), ) .child(brand_picture( "cascade-chamber", @@ -1909,7 +1114,7 @@ fn playground_page() -> View { el("div") .class("lab-hero-facts") .child(metric("Recipes", "144 exact combinations")) - .child(metric("Compiler", "pliego-cssc 0.1.0-rc.2")) + .child(metric("Compiler", "pliego-cssc 0.1.0-rc.3")) .child(metric("Boundary", "build-time corpus")), ), ) @@ -2515,12 +1720,12 @@ fn docs_index() -> View { .child(el("span").child(*category)) .child(el("span").child(format!( "{:02} entries", - DOCS.iter() + docs().iter() .filter(|document| document.category == *category) .count() ))), ); - for document in DOCS + for document in docs() .iter() .filter(|document| document.category == *category) { @@ -2562,7 +1767,7 @@ fn doc_categories() -> &'static [&'static str] { fn doc_directory_item(document: &DocPage) -> Element { el("a") .class("doc-directory-item") - .attr("href", document.route) + .attr("href", document.route.as_str()) .attr("data-doc-search-item", "") .attr( "data-search-text", @@ -2574,18 +1779,19 @@ fn doc_directory_item(document: &DocPage) -> Element { .child( el("span") .class("doc-index") - .child(format!("{:02}", list_child_index(document.route))), + .child(format!("{:02}", list_child_index(document.route.as_str()))), ) .child( el("span") - .child(el("strong").child(document.eyebrow)) - .child(el("span").child(document.summary)), + .child(el("strong").child(document.eyebrow.as_str())) + .child(el("span").child(document.summary.as_str())), ) .child(el("span").attr("aria-hidden", "true").child("↗")) } fn list_child_index(route: &str) -> usize { - DOCS.iter() + docs() + .iter() .position(|document| document.route == route) .map_or(1, |index| index + 1) } @@ -2596,18 +1802,18 @@ fn doc_page(document: &DocPage) -> View { .attr("aria-label", "On this page") .child(el("strong").child("ON THIS PAGE")); let mut content = el("article").class("doc-article"); - for section in document.sections { + for section in &document.sections { toc = toc.child( el("a") .attr("href", format!("#{}", section.id)) - .child(section.title), + .child(section.title.as_str()), ); let mut block = el("section") - .id(section.id) + .id(section.id.as_str()) .class("doc-section") - .child(el("h2").child(section.title)) - .child(el("p").child(section.body)); - if let Some(code) = section.code { + .child(el("h2").child(section.title.as_str())) + .child(el("p").child(section.body.as_str())); + if let Some(code) = §ion.code { block = block.child( el("div") .class("code-sample") @@ -2619,22 +1825,30 @@ fn doc_page(document: &DocPage) -> View { .attr("aria-label", "Copy code") .child("COPY"), ) - .child(el("pre").child(el("code").child(code))), + .child(el("pre").child(el("code").child(code.as_str()))), ); } content = content.child(block); } + content = content.child( + el("p") + .class("doc-source") + .child("SOURCE ") + .child(el("code").child(document.source_path.as_str())) + .child(" · ") + .child(el("code").child(document.source_sha256.as_str())), + ); el("div") .class("doc-page page-shell") .child(page_intro( - document.eyebrow, - document.title, - document.summary, + document.eyebrow.as_str(), + document.title.as_str(), + document.summary.as_str(), )) .child( el("div") .class("doc-layout") - .child(docs_sidebar(document.route)) + .child(docs_sidebar(document.route.as_str())) .child(content) .child(toc), ) @@ -2649,15 +1863,15 @@ fn docs_sidebar(active_route: &str) -> Element { let mut group = el("div") .class("docs-sidebar-group") .child(el("strong").child(*category)); - for document in DOCS + for document in docs() .iter() .filter(|document| document.category == *category) { - let mut link = el("a").attr("href", document.route); + let mut link = el("a").attr("href", document.route.as_str()); if document.route == active_route { link = link.class("is-active").attr("aria-current", "page"); } - group = group.child(link.child(document.eyebrow)); + group = group.child(link.child(document.eyebrow.as_str())); } if *category == "Reference" { group = group.child( @@ -2693,16 +1907,18 @@ fn benchmarks() -> View { el("section") .class("benchmark-notice") .child(kicker("CURRENT RELEASE LIMIT")) - .child(el("h2").child("Historical timing snapshots are being superseded.")) - .child(el("p").child("The prior snapshot commit is not recoverable from repository history. Current timings remain blocked until regenerated from the final clean, reachable source commit. Payload and correctness gates continue to run independently.")), + .child(el("h2").child("Benchmark Authority v2 now owns the competitor oracle.")) + .child(el("p").child("Tailwind latest, upstream v3-LTS, and frozen-release lanes are pinned for seven days and measured in alternating pairs with peak memory, gzip, Brotli, and micro/medium/large corpora. Browser/output certification v1 adds a separate 3×3 computed and bounded visual comparison; its hosted 9/9 artifact is still pending. Legacy Gate A/B numbers remain historical until reviewed clean-tree schema-2 evidence is recorded.")), ) .child( el("div") .class("benchmark-links") .child(repo_link("Methodology", "docs/benchmarks/methodology.md")) + .child(repo_link("Benchmark Authority v2", "docs/benchmarks/tailwind-benchmark-authority-v2.md")) .child(repo_link("Tailwind competitive matrix", "docs/benchmarks/tailwind-v4-competitive-matrix.md")) .child(repo_link("Reachability pruning", "docs/benchmarks/reachability-pruning.md")) - .child(repo_link("Browser validation", "docs/benchmarks/browser-validation.md")), + .child(repo_link("Browser validation", "docs/benchmarks/browser-validation.md")) + .child(repo_link("Browser/output certification v1", "docs/benchmarks/browser-output-certification-v1.md")), ) .into_view() } @@ -2814,7 +2030,7 @@ fn brand() -> View { fn changelog() -> View { el("div") .class("changelog-page page-shell") - .child(page_intro("CHANGELOG", "Release truth, not launch theater.", "The 0.1.0-rc.2 compatibility unit is published on crates.io and replayed with Rust 1.85. Final 0.1.0 remains separately blocked by the readiness record.")) + .child(page_intro("CHANGELOG", "Release truth, not launch theater.", "The 0.1.0-rc.3 compatibility unit is under exact-source verification. Publication and final 0.1.0 remain blocked by the readiness record.")) .child( el("article") .class("release-entry") @@ -2941,7 +2157,7 @@ fn legal_document(slug: &str) -> View { ( "01", "Current status", - "PliegoCSS 0.1.0-rc.2 is public-preview software published as an exact-version crates.io compatibility unit and replayed with Rust 1.85. APIs may change before 1.0, and the release-readiness record remains the authority for promotion.", + "PliegoCSS 0.1.0-rc.3 is public-preview candidate software under exact-source verification. Publication is not implied, APIs may change before 1.0, and the release-readiness record remains the authority for promotion.", ), ( "02", @@ -2991,6 +2207,11 @@ fn legal_document(slug: &str) -> View { ), ( "05", + "External adoption research", + "Voluntary G7 interviews, incident reports, and pilots are handled through private correspondence. Before any material is admitted, PliegoCSS records affirmative research and public-redacted-record consent, assigns pseudonymous identifiers, keeps raw material in private custody, and publishes only reviewed redacted fields plus a SHA-256 digest. A recruitment comment or email alone is not consent and is not evidence.", + ), + ( + "06", "Requests and contact", "For a privacy question about correspondence controlled by Celiums Solutions LLC, contact hello@pliegocss.dev with enough context to identify the relevant exchange. Do not send unrelated credentials or sensitive data.", ), @@ -3317,7 +2538,7 @@ pub fn sitemap() -> Vec { "/legal/acceptable-use/", "/accessibility/", ]; - routes.extend(DOCS.iter().map(|document| document.route)); + routes.extend(docs().iter().map(|document| document.route.as_str())); routes.sort_unstable(); routes.dedup(); diff --git a/site/src/site.css b/site/src/site.css index 09eee81..b90de46 100644 --- a/site/src/site.css +++ b/site/src/site.css @@ -2897,6 +2897,16 @@ pre { font-size: 9px; } +.doc-source { + padding: 20px 0; + border-top: 1px solid var(--line-light); + color: var(--carbon-500); + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.7; + overflow-wrap: anywhere; +} + /* Supporting pages */ .benchmark-hero { diff --git a/standards/provenance.json b/standards/provenance.json index 6a76d50..28f34ca 100644 --- a/standards/provenance.json +++ b/standards/provenance.json @@ -123,17 +123,104 @@ ], "integrity": [] }, + { + "id": "pixelmatch", + "name": "pixelmatch", + "kind": "software", + "version": "7.2.0", + "status": "versioned-package", + "license": "ISC", + "url": "https://github.com/mapbox/pixelmatch", + "relationship": "browser-certification", + "usage": "test-only", + "evidence": [ + "benchmarks/browser-output-certification-v1/authority.json", + "package.json", + "pnpm-lock.yaml", + "scripts/browser-output-certification.mjs" + ], + "integrity": [ + { + "subject": "pixelmatch@7.2.0", + "value": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==" + }, + { + "subject": "pnpm-lock.yaml", + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" + } + ] + }, + { + "id": "playwright", + "name": "Playwright", + "kind": "software", + "version": "1.61.1", + "status": "versioned-package", + "license": "Apache-2.0", + "url": "https://github.com/microsoft/playwright", + "relationship": "browser-certification", + "usage": "test-only", + "evidence": [ + ".github/workflows/browser-output-certification.yml", + "benchmarks/browser-output-certification-v1/authority.json", + "package.json", + "pnpm-lock.yaml", + "scripts/browser-output-certification.mjs" + ], + "integrity": [ + { + "subject": "playwright-core@1.61.1", + "value": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==" + }, + { + "subject": "playwright@1.61.1", + "value": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==" + }, + { + "subject": "pnpm-lock.yaml", + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" + } + ] + }, + { + "id": "pngjs", + "name": "pngjs", + "kind": "software", + "version": "7.0.0", + "status": "versioned-package", + "license": "MIT", + "url": "https://github.com/pngjs/pngjs", + "relationship": "browser-certification", + "usage": "test-only", + "evidence": [ + "benchmarks/browser-output-certification-v1/authority.json", + "package.json", + "pnpm-lock.yaml", + "scripts/browser-output-certification.mjs" + ], + "integrity": [ + { + "subject": "pngjs@7.0.0", + "value": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==" + }, + { + "subject": "pnpm-lock.yaml", + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" + } + ] + }, { "id": "tailwindcss", "name": "Tailwind CSS and CLI", "kind": "software", - "version": "4.3.2", + "version": "4.3.3", "status": "versioned-package", "license": "MIT", "url": "https://github.com/tailwindlabs/tailwindcss", "relationship": "benchmark-baseline", "usage": "benchmark-only", "evidence": [ + "benchmarks/benchmark-authority-v2/oracle.json", "benchmarks/tailwind-v4/input.css", "package.json", "pnpm-lock.yaml" @@ -143,13 +230,25 @@ "subject": "@tailwindcss/cli@4.3.2", "value": "sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw==" }, + { + "subject": "@tailwindcss/cli@4.3.3", + "value": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==" + }, { "subject": "pnpm-lock.yaml", - "value": "sha256:333f16de16053c85708ea74473a3008f314e1c3ee45490106e1462df06de93f9" + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" + }, + { + "subject": "tailwindcss@3.4.19", + "value": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==" }, { "subject": "tailwindcss@4.3.2", "value": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==" + }, + { + "subject": "tailwindcss@4.3.3", + "value": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==" } ] },