From b920974dccde8fb6d4dda98e456ea08cbc7c09bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Tue, 21 Jul 2026 16:01:44 -0500 Subject: [PATCH 01/28] test(integration): repin current pliegors --- CHANGELOG.md | 6 ++++++ integration-tests/pliegors-smoke/pliegors-contract.json | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d41377..c32f297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ pre-1.0 compatibility policy. ## [Unreleased] +### Changed + +- 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. + ## [0.1.0-rc.2] - 2026-07-21 This candidate supersedes `0.1.0-rc.1` without moving its immutable tag. It is diff --git a/integration-tests/pliegors-smoke/pliegors-contract.json b/integration-tests/pliegors-smoke/pliegors-contract.json index cecd44e..4d6f2c5 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": "b0312225e0ae5b621b27a95143f61cb538c99b6d", + "sourceSha256": "5b7e09211e80715c25f33196ee6cb1ef1de48109ca2dfdb417b15293dc7c991b", "surfaces": [ "Cargo.toml", "Cargo.lock", From 195e136437414175dc11eee249c7e54b6bb75f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Tue, 21 Jul 2026 16:11:19 -0500 Subject: [PATCH 02/28] test(integration): bind optional pliegors companion --- integration-tests/pliegors-smoke/pliegors-contract.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/pliegors-smoke/pliegors-contract.json b/integration-tests/pliegors-smoke/pliegors-contract.json index 4d6f2c5..993b4cb 100644 --- a/integration-tests/pliegors-smoke/pliegors-contract.json +++ b/integration-tests/pliegors-smoke/pliegors-contract.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "revision": "b0312225e0ae5b621b27a95143f61cb538c99b6d", - "sourceSha256": "5b7e09211e80715c25f33196ee6cb1ef1de48109ca2dfdb417b15293dc7c991b", + "revision": "b00259c12fb5b069b2a50461bd244c9f098e834b", + "sourceSha256": "7b1588be7a5c6a8036a50c47fafbf3322ab7eec9f335156aa1ef869567f8a9a4", "surfaces": [ "Cargo.toml", "Cargo.lock", From 7f0524574a60a3dd9b5590199b35b195f8cf8e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 14:56:50 -0500 Subject: [PATCH 03/28] feat: establish G0-G4 verification architecture --- .../browser-output-certification.yml | 111 ++ .github/workflows/ci.yml | 6 + CHANGELOG.md | 36 + Cargo.lock | 4 + THIRD_PARTY_NOTICES.md | 41 +- benchmarks/benchmark-authority-v2/micro.html | 15 + benchmarks/benchmark-authority-v2/oracle.json | 118 +++ .../authority.json | 119 +++ .../shared-reset.css | 59 ++ benchmarks/evidence/README.md | 6 + benchmarks/results/README.md | 13 +- .../tests/verification_cli.rs | 10 +- .../pliego-css-build/src/artifacts/audit.rs | 81 +- .../pliego-css-build/src/artifacts/catalog.rs | 18 +- crates/pliego-css-build/tests/catalog.rs | 6 +- crates/pliego-css-build/tests/css_audit.rs | 41 + .../tests/internal/asset_plan.rs | 4 +- crates/pliego-css-compiler/src/emitter.rs | 27 +- crates/pliego-css-compiler/src/engine.rs | 749 ++++++++++++++ crates/pliego-css-compiler/src/identity.rs | 8 +- crates/pliego-css-compiler/src/ir_binary.rs | 4 +- crates/pliego-css-compiler/src/lib.rs | 53 +- .../tests/container_queries.rs | 8 +- .../tests/property_contract.rs | 61 +- crates/pliego-css-config/src/dtcg.rs | 5 +- crates/pliego-css-config/src/lib.rs | 108 +- crates/pliego-css-lsp/Cargo.toml | 4 + crates/pliego-css-lsp/README.md | 20 +- crates/pliego-css-lsp/src/lib.rs | 868 +++++----------- crates/pliego-css-lsp/src/project_index.rs | 2 +- crates/pliego-css-macros/src/lib.rs | 212 ++-- .../pliego-css-ownership/tests/contracts.rs | 2 +- crates/pliego-css-source/src/migration.rs | 5 + .../tests/reversible_migration.rs | 7 + crates/pliego-css-theme/src/binary.rs | 12 +- crates/pliego-css-theme/src/lib.rs | 99 +- crates/pliego-cssc/src/explain.rs | 34 +- crates/pliego-cssc/src/inspection.rs | 2 + crates/pliego-cssc/src/main.rs | 104 +- crates/pliego-cssc/src/source_candidates.rs | 175 ++-- crates/pliego-cssc/tests/internal/unit.rs | 25 +- .../0020-share-one-pure-incremental-engine.md | 66 ++ .../0023-establish-benchmark-authority-v2.md | 75 ++ ...certify-browser-output-against-tailwind.md | 52 + docs/adr/README.md | 2 + docs/archive/README.md | 11 + .../hosted-browser-matrix-2026-07-19.json | 59 ++ .../product/next-gap-2026-07-20.json | 2 + .../product/next-gap-2026-07-20.md | 10 +- .../releases/0.1.0-rc.2-hardening-report.md} | 13 +- .../browser-output-certification-v1.md | 86 ++ docs/benchmarks/browser-validation.md | 18 +- docs/benchmarks/hosted-browser-matrix.json | 275 ++++- docs/benchmarks/methodology.md | 30 + docs/benchmarks/pliego-gate-b.md | 16 +- .../tailwind-benchmark-authority-v2.md | 74 ++ docs/benchmarks/tailwind-v4-baseline.md | 2 +- .../tailwind-v4-competitive-matrix.json | 62 +- .../tailwind-v4-competitive-matrix.md | 174 ++-- docs/contributing/release-process.md | 71 +- docs/getting-started/editor-setup.md | 85 +- docs/getting-started/first-compile.md | 2 +- docs/how-to/custom-breakpoints.md | 16 +- docs/index.md | 13 +- docs/learn/themes-and-tokens.md | 2 +- docs/product/document-authority.json | 43 + docs/product/evidence/v0.1.0-rc.2-source.json | 53 + docs/product/maturity-map.json | 44 +- docs/product/maturity-map.md | 6 + docs/product/release-readiness-0.1.0.json | 315 ++++-- docs/product/release-readiness-0.1.0.md | 233 +++-- docs/product/research-requirements.md | 6 +- docs/reference/asset-plan-schema.md | 2 +- docs/reference/bundle-plan.md | 2 +- docs/reference/cli.md | 6 +- docs/reference/compatibility.md | 34 +- docs/reference/engine-api.md | 70 ++ docs/reference/generated-catalog.md | 134 +-- docs/reference/lsp-diagnostic-corpus.md | 14 +- docs/reference/lsp.md | 211 ++-- docs/reference/public-api.md | 4 + docs/reference/standards-provenance.md | 11 +- docs/reference/style-id-format-v2.md | 14 +- docs/reference/theme-schema.md | 18 +- docs/releases/0.1.0-rc.1.md | 3 + docs/releases/0.1.0-rc.2.md | 4 + docs/site/audit-and-transform.md | 24 + docs/site/cascade.md | 24 + docs/site/composition.md | 32 + docs/site/control-artifacts.md | 29 + docs/site/critical-css.md | 19 + docs/site/editor-setup.md | 27 + docs/site/evidence.md | 24 + docs/site/first-audit.md | 28 + docs/site/getting-started.md | 36 + docs/site/installation.md | 29 + docs/site/integrations/build-tools.md | 26 + docs/site/integrations/plain-html.md | 28 + docs/site/integrations/pliegors.md | 23 + docs/site/manifests.md | 24 + docs/site/migration.md | 23 + docs/site/migration/css-modules.md | 23 + docs/site/migration/sass.md | 23 + docs/site/migration/tailwind.md | 27 + docs/site/reference/configuration.md | 19 + docs/site/reference/diagnostics.md | 23 + docs/site/release-readiness.md | 19 + docs/site/standard-css/policies.md | 28 + docs/site/syntax.md | 31 + docs/site/themes.md | 32 + docs/site/tooling/cli.md | 28 + docs/site/tooling/lsp.md | 19 + docs/site/tooling/repair.md | 29 + docs/site/tooling/watch.md | 24 + docs/site/typed-styles.md | 32 + docs/site/variants.md | 31 + docs/status/f0-verification.md | 2 +- docs/status/f0.md | 2 +- docs/status/f6.md | 37 +- docs/troubleshooting/lsp.md | 27 +- editors/neovim/README.md | 19 +- editors/neovim/lua/pliegocss/init.lua | 3 +- editors/neovim/tests/host/init.lua | 1 - editors/vscode/README.md | 29 +- editors/vscode/package.json | 5 +- editors/vscode/scripts/check-host.mjs | 4 +- editors/vscode/src/extension.js | 1 - editors/vscode/src/settings.js | 6 +- editors/vscode/tests/settings.test.js | 14 +- integration-tests/manifest-graph/expected.css | 2 +- .../manifest-graph/expected.manifest.json | 34 +- integration-tests/physical-trace/expected.css | 2 +- .../physical-trace/expected.manifest.json | 184 ++-- .../public-api-smoke/src/main.rs | 2 +- package.json | 32 +- pnpm-lock.yaml | 625 +++++++++++- scripts/benchmark-authority-v2.mjs | 127 +++ scripts/benchmark-process-metrics.rs | 171 ++++ scripts/browser-output-certification.mjs | 721 +++++++++++++ scripts/check-benchmark-authority-v2.mjs | 167 +++ scripts/check-benchmark-result-v2.mjs | 243 +++++ scripts/check-breakpoint-order-browser.mjs | 186 ++++ scripts/check-browser-output-authority.mjs | 188 ++++ scripts/check-browser-output-matrix.mjs | 341 +++++++ scripts/check-browser-output-matrix.test.mjs | 212 ++++ scripts/check-compatibility-policy.mjs | 11 +- scripts/check-document-authority.mjs | 79 ++ scripts/check-engine-boundary.mjs | 78 ++ scripts/check-getting-started.mjs | 14 +- scripts/check-hosted-browser-matrix.mjs | 47 +- scripts/check-lsp-integration.mjs | 118 +-- scripts/check-manifest-graph.mjs | 19 +- scripts/check-maturity-map.mjs | 2 +- scripts/check-migration-corpus.mjs | 10 +- scripts/check-migration-real-corpus.mjs | 10 +- scripts/check-neovim-client.mjs | 13 +- scripts/check-packages.mjs | 26 +- scripts/check-parallel-determinism.mjs | 18 +- scripts/check-physical-trace.mjs | 19 +- scripts/check-plain-html-integration.mjs | 7 +- scripts/check-pliegors-integration.mjs | 18 +- scripts/check-portability.mjs | 2 +- scripts/check-public-api.mjs | 24 +- scripts/check-release-readiness.mjs | 109 +- scripts/check-release.mjs | 90 +- scripts/check-release.test.mjs | 50 + scripts/check-repair-corpus.mjs | 3 + scripts/check-representative-css-modules.mjs | 3 +- .../check-representative-framework-routes.mjs | 3 +- .../check-representative-plain-html-audit.mjs | 3 +- scripts/check-representative-rust-control.mjs | 15 +- ...presentative-vite-tailwind-replacement.mjs | 3 +- ...epresentative-vite-tailwind-reversible.mjs | 3 +- ...sentative-vite-tailwind-template-group.mjs | 3 +- ...epresentative-vite-tailwind-theme-root.mjs | 3 +- ...k-representative-vite-tailwind-utility.mjs | 3 +- .../check-representative-vite-tailwind.mjs | 3 +- scripts/check-standards-provenance.mjs | 197 +++- scripts/check-tailwind-matrix.mjs | 116 ++- scripts/check-vscode-client.mjs | 3 +- scripts/measure-benchmark-authority-v2.mjs | 569 +++++++++++ scripts/measure-pliego-gate-a.mjs | 2 +- scripts/measure-pliego-gate-b.mjs | 870 +--------------- scripts/measure-wasm-overhead.mjs | 2 +- scripts/release-authority.mjs | 420 ++++++++ scripts/release-authority.test.mjs | 73 ++ scripts/render-benchmark-authority-v2.mjs | 114 +++ .../render-browser-output-certification.mjs | 113 ++ scripts/render-release-readiness.mjs | 167 +++ scripts/render-tailwind-matrix.mjs | 113 ++ scripts/rust-target.mjs | 56 + site/Cargo.lock | 1 + site/Cargo.toml | 1 + site/README.md | 20 +- site/scripts/build.mjs | 113 +- site/scripts/check-site.mjs | 29 +- site/scripts/generate-docs.mjs | 194 ++++ site/scripts/generate-docs.test.mjs | 35 + site/src/docs.generated.json | 906 ++++++++++++++++ site/src/pages.rs | 964 ++---------------- site/src/site.css | 10 + standards/provenance.json | 103 +- 202 files changed, 11884 insertions(+), 4012 deletions(-) create mode 100644 .github/workflows/browser-output-certification.yml create mode 100644 benchmarks/benchmark-authority-v2/micro.html create mode 100644 benchmarks/benchmark-authority-v2/oracle.json create mode 100644 benchmarks/browser-output-certification-v1/authority.json create mode 100644 benchmarks/browser-output-certification-v1/shared-reset.css create mode 100644 crates/pliego-css-compiler/src/engine.rs create mode 100644 docs/adr/0020-share-one-pure-incremental-engine.md create mode 100644 docs/adr/0023-establish-benchmark-authority-v2.md create mode 100644 docs/adr/0024-certify-browser-output-against-tailwind.md create mode 100644 docs/archive/README.md create mode 100644 docs/archive/evidence/hosted-browser-matrix-2026-07-19.json rename docs/{ => archive}/product/next-gap-2026-07-20.json (94%) rename docs/{ => archive}/product/next-gap-2026-07-20.md (67%) rename docs/{product/hardening-report-0.1.0-rc.2.md => archive/releases/0.1.0-rc.2-hardening-report.md} (94%) create mode 100644 docs/benchmarks/browser-output-certification-v1.md create mode 100644 docs/benchmarks/tailwind-benchmark-authority-v2.md create mode 100644 docs/product/document-authority.json create mode 100644 docs/product/evidence/v0.1.0-rc.2-source.json create mode 100644 docs/reference/engine-api.md create mode 100644 docs/site/audit-and-transform.md create mode 100644 docs/site/cascade.md create mode 100644 docs/site/composition.md create mode 100644 docs/site/control-artifacts.md create mode 100644 docs/site/critical-css.md create mode 100644 docs/site/editor-setup.md create mode 100644 docs/site/evidence.md create mode 100644 docs/site/first-audit.md create mode 100644 docs/site/getting-started.md create mode 100644 docs/site/installation.md create mode 100644 docs/site/integrations/build-tools.md create mode 100644 docs/site/integrations/plain-html.md create mode 100644 docs/site/integrations/pliegors.md create mode 100644 docs/site/manifests.md create mode 100644 docs/site/migration.md create mode 100644 docs/site/migration/css-modules.md create mode 100644 docs/site/migration/sass.md create mode 100644 docs/site/migration/tailwind.md create mode 100644 docs/site/reference/configuration.md create mode 100644 docs/site/reference/diagnostics.md create mode 100644 docs/site/release-readiness.md create mode 100644 docs/site/standard-css/policies.md create mode 100644 docs/site/syntax.md create mode 100644 docs/site/themes.md create mode 100644 docs/site/tooling/cli.md create mode 100644 docs/site/tooling/lsp.md create mode 100644 docs/site/tooling/repair.md create mode 100644 docs/site/tooling/watch.md create mode 100644 docs/site/typed-styles.md create mode 100644 docs/site/variants.md create mode 100644 scripts/benchmark-authority-v2.mjs create mode 100644 scripts/benchmark-process-metrics.rs create mode 100644 scripts/browser-output-certification.mjs create mode 100644 scripts/check-benchmark-authority-v2.mjs create mode 100644 scripts/check-benchmark-result-v2.mjs create mode 100644 scripts/check-breakpoint-order-browser.mjs create mode 100644 scripts/check-browser-output-authority.mjs create mode 100644 scripts/check-browser-output-matrix.mjs create mode 100644 scripts/check-browser-output-matrix.test.mjs create mode 100644 scripts/check-document-authority.mjs create mode 100644 scripts/check-engine-boundary.mjs create mode 100644 scripts/measure-benchmark-authority-v2.mjs create mode 100644 scripts/release-authority.mjs create mode 100644 scripts/release-authority.test.mjs create mode 100644 scripts/render-benchmark-authority-v2.mjs create mode 100644 scripts/render-browser-output-certification.mjs create mode 100644 scripts/render-release-readiness.mjs create mode 100644 scripts/render-tailwind-matrix.mjs create mode 100644 scripts/rust-target.mjs create mode 100644 site/scripts/generate-docs.mjs create mode 100644 site/scripts/generate-docs.test.mjs create mode 100644 site/src/docs.generated.json 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..4c707dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -247,6 +247,12 @@ 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: 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/CHANGELOG.md b/CHANGELOG.md index c32f297..a831efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,36 @@ pre-1.0 compatibility policy. ### 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. ## [0.1.0-rc.2] - 2026-07-21 @@ -22,6 +49,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..f3efbc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,9 +514,13 @@ version = "0.1.0-rc.2" name = "pliego-css-lsp" version = "0.1.0-rc.2" 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", diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2c6f03a..a0e5dbc 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 + `39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394`. + +## 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/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..fb702ff --- /dev/null +++ b/benchmarks/benchmark-authority-v2/oracle.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 2, + "status": "active", + "observedAtUtc": "2026-07-22T15:25:28.117Z", + "expiresAtUtc": "2026-07-29T15:25:28.117Z", + "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/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/crates/pliego-css-agent/tests/verification_cli.rs b/crates/pliego-css-agent/tests/verification_cli.rs index 585a667..71a11b0 100644 --- a/crates/pliego-css-agent/tests/verification_cli.rs +++ b/crates/pliego-css-agent/tests/verification_cli.rs @@ -84,8 +84,14 @@ fn temp_root() -> PathBuf { path } +fn agent_binary() -> PathBuf { + std::env::var_os("CARGO_BIN_EXE_pliego-css-agent") + .map(PathBuf::from) + .expect("Cargo must provide the pliego-css-agent binary for integration tests") +} + 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 +311,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..357d4f6 100644 --- a/crates/pliego-css-build/tests/css_audit.rs +++ b/crates/pliego-css-build/tests/css_audit.rs @@ -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-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/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/src/migration.rs b/crates/pliego-css-source/src/migration.rs index 6787f16..13ff210 100644 --- a/crates/pliego-css-source/src/migration.rs +++ b/crates/pliego-css-source/src/migration.rs @@ -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", 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-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/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..60224ab --- /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 for implementation; hosted acceptance evidence pending + +## 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/README.md b/docs/adr/README.md index a6dbcd1..9e1594b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,3 +24,5 @@ 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) 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/browser-output-certification-v1.md b/docs/benchmarks/browser-output-certification-v1.md new file mode 100644 index 0000000..3fcf867 --- /dev/null +++ b/docs/benchmarks/browser-output-certification-v1.md @@ -0,0 +1,86 @@ +# Browser/output certification v1 + +Status: **implemented local harness; hosted 3×3 evidence pending** + +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/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/methodology.md b/docs/benchmarks/methodology.md index 3e4c81c..7e4e8c5 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 | 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/tailwind-benchmark-authority-v2.md b/docs/benchmarks/tailwind-benchmark-authority-v2.md new file mode 100644 index 0000000..9ec91fc --- /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-07-22T15:25:28.117Z`, expires at `2026-07-29T15:25:28.117Z`, 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..b9af48b 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", @@ -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..dc97498 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-07-22T15:25:28.117Z`. +Expiration: `2026-07-29T15:25:28.117Z`. 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.2 crate graph, CLI, and LSP are published on crates.io and replayed with Rust 1.85; editor clients remain source-distributed candidates. | 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/contributing/release-process.md b/docs/contributing/release-process.md index 6effab4..7cca56e 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -7,8 +7,9 @@ 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 @@ -48,11 +49,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 +103,16 @@ 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 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 +216,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 +282,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 +338,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 @@ -338,22 +360,25 @@ release commit after upload; any correction requires a new package version. ## 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 currently binds candidate `0.1.0-rc.2` to commit +`064dcbce96a3a5cc97a940d07566c008bb5e2d3e` and Git tree +`010a64f5d0bbd142a1d015fda4ccf573cc6e4133`. Exact-candidate CI and CodeQL are +passed; they do not clear these remaining gates: + +- G0 corrections still exist only in a later worktree and need a reviewed commit/tree plus hosted + replay before they can become candidate evidence; +- all required hosted browser lanes are `not-configured` for the exact candidate; the G4 9-host + harness exists locally, but its clean commit-bound workflow artifact has not yet been produced; +- the crates.io replay and production edge/browser replay need fresh, unexpired, artifact-hashed + evidence; +- the LTS distribution boundary still lacks signed native binaries, checksums, SBOMs, attestations, + and the npm launcher; +- 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..f5fa00f 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-cssc --version '=0.1.0-rc.2' --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..b964784 100644 --- a/docs/getting-started/first-compile.md +++ b/docs/getting-started/first-compile.md @@ -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/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/index.md b/docs/index.md index 84c161e..45dd479 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) @@ -109,8 +110,10 @@ 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) +- [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 +130,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,6 +142,7 @@ 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) 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/document-authority.json b/docs/product/document-authority.json new file mode 100644 index 0000000..a852f3c --- /dev/null +++ b/docs/product/document-authority.json @@ -0,0 +1,43 @@ +{ + "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": [ + "EXECUTION_PLAN_ULTRA.md", + "docs/product/strategic-product-contract-2026.md", + "docs/product/maturity-map.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.", + "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/maturity-map.json b/docs/product/maturity-map.json index 908e7d3..727bdef 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,14 +151,36 @@ "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."] }, @@ -177,10 +199,10 @@ "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."] }, diff --git a/docs/product/maturity-map.md b/docs/product/maturity-map.md index e055db0..5905574 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,6 +44,9 @@ 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; - watch mode; diff --git a/docs/product/release-readiness-0.1.0.json b/docs/product/release-readiness-0.1.0.json index 253d25e..3fb9ff9 100644 --- a/docs/product/release-readiness-0.1.0.json +++ b/docs/product/release-readiness-0.1.0.json @@ -1,103 +1,298 @@ { - "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", + "id": "exact-candidate-ci", + "dimension": "technical", "status": "passed", "evidenceClass": "measured", "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.", + "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", - "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." - }, - { - "id": "pliegors-current-contract", + "id": "exact-candidate-codeql", + "dimension": "technical", "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." + "summary": "CodeQL run 29864046876 passed Actions, JavaScript/TypeScript, and Rust analysis on the exact RC.2 commit.", + "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": "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", - "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." - }, - { - "id": "benchmark-evidence-current-source", - "status": "passed", - "evidenceClass": "measured", + "id": "g0-corrections-on-candidate", + "dimension": "technical", + "status": "pending", + "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": "G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and 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": "supply-chain-policy", - "status": "passed", - "evidenceClass": "measured", + "id": "browser-release-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": "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": "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": "Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet.", + "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", "sbom", "attestations", "npm-launcher"], + "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": "The required external interviews, real incidents, and pilots have not been recorded as reviewed 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"], + "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..e353a57 100644 --- a/docs/product/release-readiness-0.1.0.md +++ b/docs/product/release-readiness-0.1.0.md @@ -1,82 +1,155 @@ # 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 | **passed** | measured | 9/9 | none | +| `exact-candidate-codeql` | technical | **passed** | measured | 3/3 | none | +| `g0-corrections-on-candidate` | technical | **pending** | pending | 0/4 | none | +| `browser-release-matrix` | technical | **blocked** | pending | 0/5 | 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/7 | none | +| `external-adoption-evidence` | adoption | **pending** | pending | 0/3 | 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. + +- 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. + +- 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 exist only in the later uncommitted worktree. They require a reviewed commit/tree and 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) + +### 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 + +Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. + +- 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`, `sbom`, `attestations`, `npm-launcher` +- Covered: none +- Hashed artifacts: none + +### external-adoption-evidence + +The required external interviews, real incidents, and pilots have not been recorded as reviewed 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` +- 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/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/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..462dd2d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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", diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index ba69f8e..85cdeb4 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -23,24 +23,24 @@ must pin every direct PliegoCSS dependency to exact version `0.1.0-rc.2`. | 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 | @@ -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 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..d377173 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.2' --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/public-api.md b/docs/reference/public-api.md index 96b6027..c097fc3 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -345,6 +345,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/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..f2e5149 100644 --- a/docs/reference/style-id-format-v2.md +++ b/docs/reference/style-id-format-v2.md @@ -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/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/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/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..98a6544 --- /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 complete 0.1.0-rc.2 compatibility unit is published on crates.io and replayed from the registry with Rust 1.85. + +```console +cargo install pliego-cssc --version '=0.1.0-rc.2' --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..310dbe3 --- /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.2' --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.2" +``` + +## 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..f4dca17 --- /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} + +g0-corrections-on-candidate (pending): G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and 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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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/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..36ec0df 100644 --- a/editors/vscode/scripts/check-host.mjs +++ b/editors/vscode/scripts/check-host.mjs @@ -11,7 +11,6 @@ const target = process.env.CARGO_TARGET_DIR 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) { @@ -34,7 +33,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 +66,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/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/public-api-smoke/src/main.rs b/integration-tests/public-api-smoke/src/main.rs index 8f80f08..575f191 100644 --- a/integration-tests/public-api-smoke/src/main.rs +++ b/integration-tests/public-api-smoke/src/main.rs @@ -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", diff --git a/package.json b/package.json index db0d1b9..5a07496 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,18 @@ "scripts": { "baseline:build": "tailwindcss -i ./benchmarks/tailwind-v4/input.css -o ./benchmarks/tailwind-v4/dist/full-complete.min.css --minify", "baseline:watch": "tailwindcss -i ./benchmarks/tailwind-v4/input.css -o ./benchmarks/tailwind-v4/dist/full-complete.min.css --watch", - "baseline:measure": "node ./scripts/measure-tailwind-baseline.mjs", + "baseline:measure": "node ./scripts/measure-benchmark-authority-v2.mjs", + "baseline:measure-legacy-v4": "node ./scripts/measure-tailwind-baseline.mjs", "baseline:measure-watch": "node ./scripts/measure-tailwind-watch.mjs", "baseline:measure-rust": "node ./scripts/measure-rust-check.mjs", "baseline:measure-pliego": "node ./scripts/measure-pliego-gate-a.mjs", - "baseline:measure-pliego-b": "node ./scripts/measure-pliego-gate-b.mjs", + "baseline:measure-pliego-b": "node ./scripts/measure-benchmark-authority-v2.mjs", + "baseline:measure-authority-v2": "node ./scripts/measure-benchmark-authority-v2.mjs", "baseline:measure-media": "node ./scripts/measure-media-merge.mjs", "baseline:measure-wasm": "node ./scripts/measure-wasm-overhead.mjs", "baseline:check-diagnostics": "node ./scripts/check-diagnostics.mjs", "check:api": "node ./scripts/check-public-api.mjs && pnpm check:manifest && pnpm check:trace", + "check:engine-boundary": "node ./scripts/check-engine-boundary.mjs", "check:determinism": "node ./scripts/check-parallel-determinism.mjs", "check:properties": "cargo +1.85.0 test --locked -p pliego-css-compiler --test property_contract", "check:fuzz": "node ./scripts/check-fuzz.mjs", @@ -26,20 +29,31 @@ "check:media": "node ./scripts/measure-media-merge.mjs --check", "check:pruning": "node ./scripts/measure-reachability-pruning.mjs --check", "check:evidence": "node ./scripts/check-benchmark-evidence.mjs", + "check:benchmark-authority": "node ./scripts/check-benchmark-authority-v2.mjs && node ./scripts/render-benchmark-authority-v2.mjs --check", + "check:benchmark-oracle": "node ./scripts/check-benchmark-authority-v2.mjs --network", + "check:benchmark-result": "node ./scripts/check-benchmark-result-v2.mjs", + "check:benchmark-smoke": "node ./scripts/measure-benchmark-authority-v2.mjs --smoke && node ./scripts/check-benchmark-result-v2.mjs", "check:portability": "cargo build --locked -p pliego-cssc && node ./scripts/check-portability.mjs", "check:compatibility": "node ./scripts/check-compatibility-policy.mjs", "check:attribution": "node ./scripts/check-standards-provenance.mjs", "check:docs": "node ./scripts/check-docs.mjs", + "check:site-docs": "node ./site/scripts/generate-docs.test.mjs && node ./site/scripts/generate-docs.mjs --check", "check:brand": "node ./scripts/check-brand.mjs", "check:maturity": "node ./scripts/check-maturity-map.mjs", "check:tailwind-matrix": "node ./scripts/check-tailwind-matrix.mjs", "check:representative-gaps": "node ./scripts/check-representative-gap-map.mjs", "check:css-classifier": "node ./scripts/check-standard-css-classifier-corpus.mjs", - "check:hosted-browser-matrix": "node ./scripts/check-hosted-browser-matrix.mjs", + "check:hosted-browser-matrix": "node ./scripts/check-hosted-browser-matrix.mjs --validate", + "check:browser-output-authority": "node ./scripts/check-browser-output-authority.mjs && node ./scripts/check-browser-output-matrix.test.mjs && node ./scripts/render-browser-output-certification.mjs --check", + "check:browser-output": "node ./scripts/browser-output-certification.mjs", + "check:browser-output-matrix": "node ./scripts/check-browser-output-matrix.mjs", + "check:release-authority": "node ./scripts/release-authority.test.mjs && node ./scripts/check-release-readiness.mjs --validate && node ./scripts/check-hosted-browser-matrix.mjs --validate && node ./scripts/check-document-authority.mjs && pnpm check:site-docs", "integration:browser:chrome": "node ./scripts/check-local-cdp-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", "integration:browser:edge": "node ./scripts/check-local-cdp-browser.mjs \"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\"", "integration:browser:theme-root:chrome": "node ./scripts/check-theme-root-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", "integration:browser:theme-root:edge": "node ./scripts/check-theme-root-browser.mjs \"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\"", + "integration:browser:breakpoints:chrome": "node ./scripts/check-breakpoint-order-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", + "integration:browser:breakpoints:edge": "node ./scripts/check-breakpoint-order-browser.mjs \"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\"", "integration:browser:utility:chrome": "node ./scripts/check-utility-projection-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", "integration:browser:utility:edge": "node ./scripts/check-utility-projection-browser.mjs \"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\"", "integration:browser:template-group:chrome": "node ./scripts/check-template-group-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", @@ -82,11 +96,17 @@ "bench:migration-adoption": "node ./scripts/measure-migration-adoption.mjs" }, "devDependencies": { - "@tailwindcss/cli": "4.3.2", + "@tailwindcss/cli": "4.3.3", + "@tailwindcss/cli-frozen": "npm:@tailwindcss/cli@4.3.2", "esbuild": "0.28.1", "gsap": "3.15.0", "lenis": "1.3.25", - "three": "0.185.1", - "tailwindcss": "4.3.2" + "pixelmatch": "7.2.0", + "playwright": "1.61.1", + "pngjs": "7.0.0", + "tailwindcss": "4.3.3", + "tailwindcss-frozen": "npm:tailwindcss@4.3.2", + "tailwindcss-v3-lts": "npm:tailwindcss@3.4.19", + "three": "0.185.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8786f2b..85ab5e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ importers: .: devDependencies: '@tailwindcss/cli': - specifier: 4.3.2 - version: 4.3.2 + specifier: 4.3.3 + version: 4.3.3 + '@tailwindcss/cli-frozen': + specifier: npm:@tailwindcss/cli@4.3.2 + version: '@tailwindcss/cli@4.3.2' esbuild: specifier: 0.28.1 version: 0.28.1 @@ -20,9 +23,24 @@ importers: lenis: specifier: 1.3.25 version: 1.3.25 + pixelmatch: + specifier: 7.2.0 + version: 7.2.0 + playwright: + specifier: 1.61.1 + version: 1.61.1 + pngjs: + specifier: 7.0.0 + version: 7.0.0 tailwindcss: - specifier: 4.3.2 - version: 4.3.2 + specifier: 4.3.3 + version: 4.3.3 + tailwindcss-frozen: + specifier: npm:tailwindcss@4.3.2 + version: tailwindcss@4.3.2 + tailwindcss-v3-lts: + specifier: npm:tailwindcss@3.4.19 + version: tailwindcss@3.4.19 three: specifier: 0.185.1 version: 0.185.1 @@ -73,6 +91,10 @@ importers: packages: + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -678,39 +700,76 @@ packages: resolution: {integrity: sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw==} hasBin: true + '@tailwindcss/cli@4.3.3': + resolution: {integrity: sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==} + hasBin: true + '@tailwindcss/node@4.3.2': resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + '@tailwindcss/oxide-android-arm64@4.3.2': resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} engines: {node: '>= 20'} cpu: [arm64] os: [android] + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + '@tailwindcss/oxide-darwin-arm64@4.3.2': resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.3.2': resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + '@tailwindcss/oxide-freebsd-x64@4.3.2': resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} engines: {node: '>= 20'} cpu: [arm] os: [linux] + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} engines: {node: '>= 20'} @@ -718,6 +777,13 @@ packages: os: [linux] libc: [glibc] + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} @@ -725,6 +791,13 @@ packages: os: [linux] libc: [musl] + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} @@ -732,6 +805,13 @@ packages: os: [linux] libc: [glibc] + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@tailwindcss/oxide-linux-x64-musl@4.3.2': resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} engines: {node: '>= 20'} @@ -739,6 +819,13 @@ packages: os: [linux] libc: [musl] + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + '@tailwindcss/oxide-wasm32-wasi@4.3.2': resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} engines: {node: '>=14.0.0'} @@ -751,22 +838,50 @@ packages: - '@emnapi/wasi-threads' - tslib + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + '@tailwindcss/oxide@4.3.2': resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} engines: {node: '>= 20'} + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + '@textlint/ast-node-types@15.7.1': resolution: {integrity: sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==} @@ -878,6 +993,16 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -898,6 +1023,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -943,6 +1072,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -958,6 +1091,10 @@ packages: resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} engines: {node: '>=20.18.1'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -988,6 +1125,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -1002,6 +1143,11 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1044,6 +1190,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1084,6 +1236,10 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.24.3: + resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1141,6 +1297,15 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1156,6 +1321,11 @@ packages: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1183,6 +1353,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -1260,6 +1434,14 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1309,6 +1491,10 @@ packages: resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} engines: {node: '>=4'} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1449,6 +1635,13 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} @@ -1566,6 +1759,14 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -1587,9 +1788,21 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1632,6 +1845,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1656,6 +1872,32 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@2.0.0: resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} @@ -1663,6 +1905,57 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.21: + resolution: {integrity: sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==} + engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -1693,6 +1986,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + read-pkg@9.0.1: resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} engines: {node: '>=18'} @@ -1708,10 +2004,19 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -1844,6 +2149,11 @@ packages: structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -1856,13 +2166,25 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + tailwindcss@4.3.2: resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -1885,9 +2207,20 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.185.1: resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -1896,6 +2229,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2045,6 +2381,8 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -2545,6 +2883,16 @@ snapshots: picocolors: 1.1.1 tailwindcss: 4.3.2 + '@tailwindcss/cli@4.3.3': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + enhanced-resolve: 5.24.3 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.3.3 + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2555,42 +2903,88 @@ snapshots: source-map-js: 1.2.1 tailwindcss: 4.3.2 + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + '@tailwindcss/oxide-android-arm64@4.3.2': optional: true + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + '@tailwindcss/oxide-darwin-arm64@4.3.2': optional: true + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + '@tailwindcss/oxide-darwin-x64@4.3.2': optional: true + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + '@tailwindcss/oxide-freebsd-x64@4.3.2': optional: true + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': optional: true + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': optional: true + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': optional: true + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': optional: true + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + '@tailwindcss/oxide-linux-x64-musl@4.3.2': optional: true + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.2': optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': optional: true + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + '@tailwindcss/oxide@4.3.2': optionalDependencies: '@tailwindcss/oxide-android-arm64': 4.3.2 @@ -2606,6 +3000,21 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + '@textlint/ast-node-types@15.7.1': {} '@textlint/linter-formatter@15.7.1': @@ -2759,6 +3168,15 @@ snapshots: dependencies: color-convert: 2.0.1 + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + argparse@2.0.1: {} astral-regex@2.0.0: {} @@ -2775,6 +3193,8 @@ snapshots: base64-js@1.5.1: optional: true + binary-extensions@2.3.0: {} + binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -2824,6 +3244,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2854,6 +3276,18 @@ snapshots: undici: 7.28.0 whatwg-mimetype: 4.0.0 + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chownr@1.1.4: optional: true @@ -2877,6 +3311,8 @@ snapshots: commander@12.1.0: {} + commander@4.1.1: {} + cookie@1.1.1: {} core-util-is@1.0.3: {} @@ -2891,6 +3327,8 @@ snapshots: css-what@6.2.2: {} + cssesc@3.0.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2918,6 +3356,10 @@ snapshots: detect-libc@2.1.2: {} + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -2969,6 +3411,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + enhanced-resolve@5.24.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@6.0.1: {} @@ -3042,6 +3489,10 @@ snapshots: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3063,6 +3514,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3095,6 +3549,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -3175,6 +3633,14 @@ snapshots: ini@1.3.8: optional: true + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -3209,6 +3675,8 @@ snapshots: editions: 6.22.0 textextensions: 6.11.0 + jiti@1.21.7: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -3325,6 +3793,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -3427,6 +3899,14 @@ snapshots: mute-stream@0.0.8: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.16: {} + napi-build-utils@2.0.0: optional: true @@ -3451,10 +3931,16 @@ snapshots: semver: 7.8.5 validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + object-inspect@1.13.4: {} once@1.4.0: @@ -3512,6 +3998,8 @@ snapshots: dependencies: entities: 6.0.1 + path-parse@1.0.7: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.5.2 @@ -3529,10 +4017,67 @@ snapshots: picomatch@2.3.2: {} + picomatch@4.0.5: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@2.0.0: {} pluralize@8.0.0: {} + pngjs@7.0.0: {} + + postcss-import@15.1.0(postcss@8.5.21): + dependencies: + postcss: 8.5.21 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.21): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.21 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.21): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.21 + + postcss-nested@6.2.0(postcss@8.5.21): + dependencies: + postcss: 8.5.21 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.21: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -3583,6 +4128,10 @@ snapshots: strip-json-comments: 2.0.1 optional: true + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + read-pkg@9.0.1: dependencies: '@types/normalize-package-data': 2.4.4 @@ -3612,8 +4161,19 @@ snapshots: util-deprecate: 1.0.2 optional: true + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + require-from-string@2.0.2: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -3786,6 +4346,16 @@ snapshots: dependencies: boundary: 2.0.0 + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} supports-color@7.2.0: @@ -3797,6 +4367,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} + table@6.9.0: dependencies: ajv: 8.20.0 @@ -3805,8 +4377,38 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.21 + postcss-import: 15.1.0(postcss@8.5.21) + postcss-js: 4.1.0(postcss@8.5.21) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.21) + postcss-nested: 6.2.0(postcss@8.5.21) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + tailwindcss@4.3.2: {} + tailwindcss@4.3.3: {} + tapable@2.3.3: {} tar-fs@2.1.5: @@ -3837,14 +4439,29 @@ snapshots: dependencies: editions: 6.22.0 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + three@0.185.1: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} tunnel-agent@0.6.0: 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..e41ccb3 --- /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: 240_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/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..c1f76d6 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", +}); +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, }); 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-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-lsp-integration.mjs b/scripts/check-lsp-integration.mjs index c8f7e92..320e0ee 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", +}); +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"), ); @@ -23,24 +25,11 @@ 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"] }, + { 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..6c854ef 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", +}); +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, }); @@ -68,7 +77,7 @@ function resolveExecutable() { } run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { - timeout: 120_000, + 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..d791019 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", +}); +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..2bfed22 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 = [ @@ -58,14 +59,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: EXPECTED_MSRV, +}); 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 +95,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 +629,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 +780,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 +797,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..6dbffc7 100644 --- a/scripts/check-parallel-determinism.mjs +++ b/scripts/check-parallel-determinism.mjs @@ -11,8 +11,14 @@ 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 cargoEnvironment = isolatedCargoEnvironment(root, { + env: process.env, + toolchain: "1.85.0", +}); +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 +48,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 +68,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, @@ -99,8 +105,7 @@ function resolveExecutable() { } run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"]); const executable = join( - root, - "target", + cargoTarget, "debug", process.platform === "win32" ? "pliego-cssc.exe" : "pliego-cssc", ); @@ -120,12 +125,9 @@ function resolveLockHolder() { "--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" diff --git a/scripts/check-physical-trace.mjs b/scripts/check-physical-trace.mjs index d7f80d5..62f4e98 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", +}); +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, @@ -103,7 +112,7 @@ function resolveExecutable() { } run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { - timeout: 180_000, + 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-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..35d6823 100644 --- a/scripts/check-portability.mjs +++ b/scripts/check-portability.mjs @@ -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 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..0522d41 100644 --- a/scripts/check-release-readiness.mjs +++ b/scripts/check-release-readiness.mjs @@ -1,90 +1,37 @@ -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { AuthorityError, validateReleaseReadiness } from "./release-authority.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}`); - } - if (check.status === "passed" && check.evidenceClass === "pending") { - fail(`${check.id} cannot pass on pending evidence`); + +try { + const value = JSON.parse( + readFileSync(resolve(root, "docs/product/release-readiness-0.1.0.json"), "utf8"), + ); + const summary = validateReleaseReadiness(value, { root }); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (enforceRelease && summary.result !== "ready") { + process.stderr.write( + `release readiness: promotion blocked by ${summary.blockers.join(", ")}\n`, + ); + process.exitCode = 1; } - if (!check.summary || typeof check.summary !== "string") { - fail(`${check.id} must include a summary`); +} 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..c326812 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,24 @@ 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: "rust-tests", tier: "fast", @@ -101,16 +120,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"], + args: ["scripts/check-hosted-browser-matrix.mjs", "--validate"], + }, + { + id: "document-authority", + tier: "fast", + command: "node", + args: ["scripts/check-document-authority.mjs"], + }, + { + id: "site-markdown-docs", + tier: "fast", + command: "pnpm", + args: ["check:site-docs"], }, { id: "getting-started", @@ -118,6 +155,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", @@ -233,12 +276,25 @@ 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: "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 +315,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 +399,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 +419,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 +434,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 +444,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..d9b66fc 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,14 @@ 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 === "release-readiness")); assert(!fast.gates.some((gate) => gate.id === "getting-started")); const integration = list("integration"); @@ -40,6 +49,7 @@ 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")); const release = list("release"); assert.deepEqual(release.includes, ["fast", "integration", "release"]); @@ -51,6 +61,15 @@ 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.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 +87,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, 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-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-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..9e10673 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": "8c3afb5b3b6735c2bac0696872df513e372541606ebb117c3349a6cde8a7fc79", + "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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"}]}, + {"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"}]}, + {"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"}]}, + {"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"},{"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..8880ee9 100644 --- a/scripts/check-vscode-client.mjs +++ b/scripts/check-vscode-client.mjs @@ -54,9 +54,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/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-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-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/release-authority.mjs b/scripts/release-authority.mjs new file mode 100644 index 0000000..9ea4a54 --- /dev/null +++ b/scripts/release-authority.mjs @@ -0,0 +1,420 @@ +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", + "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..1a6c685 --- /dev/null +++ b/scripts/release-authority.test.mjs @@ -0,0 +1,73 @@ +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].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..404c3d9 --- /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 local harness; hosted 3×3 evidence pending** + +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-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/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..bc5ae5c 100644 --- a/site/Cargo.lock +++ b/site/Cargo.lock @@ -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..d974022 100644 --- a/site/Cargo.toml +++ b/site/Cargo.toml @@ -19,4 +19,5 @@ bench = false pliego-css = { path = "../crates/pliego-css", version = "=0.1.0-rc.2" } 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/scripts/build.mjs b/site/scripts/build.mjs index 6d48f4b..60202f7 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", @@ -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..835acf4 100644 --- a/site/scripts/check-site.mjs +++ b/site/scripts/check-site.mjs @@ -81,6 +81,7 @@ function staticContract() { "docs/integrations/pliegors/index.html", "docs/tooling/repair/index.html", "docs/reference/diagnostics/index.html", + "docs/release-readiness/index.html", "docs/utilities/index.html", "playground/index.html", "examples/index.html", @@ -97,6 +98,7 @@ function staticContract() { "es/index.html", "es/docs/index.html", "es/docs/getting-started/index.html", + "es/docs/release-readiness/index.html", "es/docs/utilities/index.html", "es/playground/index.html", "es/examples/index.html", @@ -211,12 +213,35 @@ function staticContract() { const sitemap = readFileSync(join(output, "sitemap.xml"), "utf8"); const sitemapUrls = sitemap.match(//gu)?.length ?? 0; if ( - sitemapUrls !== 88 || + sitemapUrls !== 90 || !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 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 laboratory = JSON.parse( readFileSync(join(output, "assets", "laboratory.json")), ); @@ -651,7 +676,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..823460b --- /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, 30); +assert.equal(new Set(manifest.documents.map((document) => document.route)).size, 30); +assert.equal(manifest.documents.at(-1).route, "/docs/release-readiness/"); +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..3ba73dd --- /dev/null +++ b/site/src/docs.generated.json @@ -0,0 +1,906 @@ +{ + "schemaVersion": 1, + "kind": "pliegocss-site-markdown", + "documents": [ + { + "sourcePath": "docs/site/getting-started.md", + "sourceSha256": "sha256:759274aa8cd671310039549403f44456199a2ebd624e89f56e375aa875c66ded", + "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 complete 0.1.0-rc.2 compatibility unit is published on crates.io and replayed from the registry with Rust 1.85.", + "code": "cargo install pliego-cssc --version '=0.1.0-rc.2' --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:2459d3c1c9082a6107df25e9dbc5b461623c182f064b623216dc509a17aa23ec", + "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.2' --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.2\"" + }, + { + "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:1c590a940d6876b5f0d2ee180e689eb07ae1412660801352c22da6d72955626b", + "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": "g0-corrections-on-candidate (pending): G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and 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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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 + } + ] + } + ] +} diff --git a/site/src/pages.rs b/site/src/pages.rs index a20ad0b..e9d2fcf 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 })); } @@ -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() } @@ -3317,7 +2533,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..3a0f873 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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + } + ] + }, + { + "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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + } + ] + }, + { + "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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + } + ] + }, { "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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + }, + { + "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==" } ] }, From 4349d1258d25506c1e4c0d1a1597524f3f44e7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 15:13:12 -0500 Subject: [PATCH 04/28] fix: harden hosted verification portability --- .github/workflows/ci.yml | 6 ++++-- .../tests/verification_cli.rs | 19 ++++++++++++++++--- scripts/browser-output-certification.mjs | 2 +- scripts/check-compatibility-policy.mjs | 4 ++-- scripts/check-lsp-integration.mjs | 4 ++-- scripts/check-manifest-graph.mjs | 4 ++-- scripts/check-neovim-client.mjs | 2 +- scripts/check-packages.mjs | 3 ++- scripts/check-physical-trace.mjs | 4 ++-- 9 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c707dd..321b9e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,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 +132,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 diff --git a/crates/pliego-css-agent/tests/verification_cli.rs b/crates/pliego-css-agent/tests/verification_cli.rs index 71a11b0..b4f0c4c 100644 --- a/crates/pliego-css-agent/tests/verification_cli.rs +++ b/crates/pliego-css-agent/tests/verification_cli.rs @@ -85,9 +85,22 @@ fn temp_root() -> PathBuf { } fn agent_binary() -> PathBuf { - std::env::var_os("CARGO_BIN_EXE_pliego-css-agent") - .map(PathBuf::from) - .expect("Cargo must provide the pliego-css-agent binary for integration tests") + 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 { diff --git a/scripts/browser-output-certification.mjs b/scripts/browser-output-certification.mjs index e41ccb3..2309a75 100644 --- a/scripts/browser-output-certification.mjs +++ b/scripts/browser-output-certification.mjs @@ -98,7 +98,7 @@ function run(command, arguments_, options = {}) { cwd: repositoryRoot, encoding: "utf8", windowsHide: true, - timeout: 240_000, + timeout: 600_000, maxBuffer: 32 * 1024 * 1024, ...options, }); diff --git a/scripts/check-compatibility-policy.mjs b/scripts/check-compatibility-policy.mjs index c1f76d6..b7336c5 100644 --- a/scripts/check-compatibility-policy.mjs +++ b/scripts/check-compatibility-policy.mjs @@ -7,7 +7,7 @@ import { cargoTargetRoot, isolatedCargoEnvironment } from "./rust-target.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const cargoEnvironment = isolatedCargoEnvironment(root, { env: process.env, - toolchain: "1.85", + toolchain: "1.85.0", }); const targetDir = cargoTargetRoot(root, cargoEnvironment); const runtime = join(targetDir, "compatibility-policy", `run-${process.pid}-${Date.now()}`); @@ -48,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-lsp-integration.mjs b/scripts/check-lsp-integration.mjs index 320e0ee..c6396a3 100644 --- a/scripts/check-lsp-integration.mjs +++ b/scripts/check-lsp-integration.mjs @@ -9,7 +9,7 @@ const ROOT = resolve(import.meta.dirname, ".."); const executable = process.platform === "win32" ? ".exe" : ""; const cargoEnvironment = isolatedCargoEnvironment(ROOT, { env: process.env, - toolchain: "1.85", + toolchain: "1.85.0", }); const target = cargoTargetRoot(ROOT, cargoEnvironment); const lsp = resolve(target, "debug", `pliego-css-lsp${executable}`); @@ -24,7 +24,7 @@ function fail(message) { const build = spawnSync( "cargo", - ["+1.85", "build", "--locked", "-p", "pliego-css-lsp", "-p", "pliego-cssc"], + ["+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}`); diff --git a/scripts/check-manifest-graph.mjs b/scripts/check-manifest-graph.mjs index 6c854ef..a2b554d 100644 --- a/scripts/check-manifest-graph.mjs +++ b/scripts/check-manifest-graph.mjs @@ -21,7 +21,7 @@ const runtime = join(root, "target", "manifest-graph", `run-${process.pid}-${Dat const project = join(runtime, "project"); const cargoEnvironment = isolatedCargoEnvironment(root, { env: process.env, - toolchain: "1.85", + toolchain: "1.85.0", }); const targetDir = cargoTargetRoot(root, cargoEnvironment); const expectedCss = readFileSync(join(fixture, "expected.css")); @@ -76,7 +76,7 @@ function resolveExecutable() { return configured; } - run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { + run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"], { timeout: cargoBuildTimeout, }); const executable = join( diff --git a/scripts/check-neovim-client.mjs b/scripts/check-neovim-client.mjs index d791019..e24d163 100644 --- a/scripts/check-neovim-client.mjs +++ b/scripts/check-neovim-client.mjs @@ -98,7 +98,7 @@ if (!existsSync(executable)) fail(`Neovim executable is missing after extraction const cargoEnvironment = isolatedCargoEnvironment(ROOT, { env: process.env, - toolchain: "1.85", + toolchain: "1.85.0", }); const target = cargoTargetRoot(ROOT, cargoEnvironment); const workspace = resolve(target, "lsp-integration-workspace"); diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs index 2bfed22..74d2527 100644 --- a/scripts/check-packages.mjs +++ b/scripts/check-packages.mjs @@ -52,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"; @@ -65,7 +66,7 @@ const packageCargoEnvironment = isolatedCargoEnvironment(ROOT, { }); const msrvCargoEnvironment = isolatedCargoEnvironment(ROOT, { env: process.env, - toolchain: EXPECTED_MSRV, + toolchain: MSRV_TOOLCHAIN, }); function fail(message) { diff --git a/scripts/check-physical-trace.mjs b/scripts/check-physical-trace.mjs index 62f4e98..89cd875 100644 --- a/scripts/check-physical-trace.mjs +++ b/scripts/check-physical-trace.mjs @@ -20,7 +20,7 @@ const runtime = join(root, "target", "physical-trace", `run-${process.pid}-${Dat const project = join(runtime, "project"); const cargoEnvironment = isolatedCargoEnvironment(root, { env: process.env, - toolchain: "1.85", + toolchain: "1.85.0", }); const targetDir = cargoTargetRoot(root, cargoEnvironment); const reachabilityName = "pliego.reachability.json"; @@ -111,7 +111,7 @@ function resolveExecutable() { return configured; } - run("cargo", ["+1.85", "build", "--locked", "-p", "pliego-cssc"], { + run("cargo", ["+1.85.0", "build", "--locked", "-p", "pliego-cssc"], { timeout: cargoBuildTimeout, }); const executable = join( From 463d4af4153f4033eb33cd2e4b4c243b131b0436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 15:15:59 -0500 Subject: [PATCH 05/28] docs: bind G4 evidence to release candidates --- docs/adr/0024-certify-browser-output-against-tailwind.md | 2 +- docs/benchmarks/browser-output-certification-v1.md | 2 +- docs/contributing/release-process.md | 8 ++++---- docs/product/release-readiness-0.1.0.json | 2 +- docs/product/release-readiness-0.1.0.md | 2 +- docs/site/release-readiness.md | 2 +- scripts/render-browser-output-certification.mjs | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/adr/0024-certify-browser-output-against-tailwind.md b/docs/adr/0024-certify-browser-output-against-tailwind.md index 60224ab..bfccc7f 100644 --- a/docs/adr/0024-certify-browser-output-against-tailwind.md +++ b/docs/adr/0024-certify-browser-output-against-tailwind.md @@ -2,7 +2,7 @@ Date: 2026-07-22 -Status: Accepted for implementation; hosted acceptance evidence pending +Status: Accepted and implemented; release-candidate acceptance evidence remains source-bound ## Context diff --git a/docs/benchmarks/browser-output-certification-v1.md b/docs/benchmarks/browser-output-certification-v1.md index 3fcf867..65f68c4 100644 --- a/docs/benchmarks/browser-output-certification-v1.md +++ b/docs/benchmarks/browser-output-certification-v1.md @@ -1,6 +1,6 @@ # Browser/output certification v1 -Status: **implemented local harness; hosted 3×3 evidence pending** +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 diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index 7cca56e..f4a8af9 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -365,10 +365,10 @@ The schema-3 authority currently binds candidate `0.1.0-rc.2` to commit `010a64f5d0bbd142a1d015fda4ccf573cc6e4133`. Exact-candidate CI and CodeQL are passed; they do not clear these remaining gates: -- G0 corrections still exist only in a later worktree and need a reviewed commit/tree plus hosted - replay before they can become candidate evidence; -- all required hosted browser lanes are `not-configured` for the exact candidate; the G4 9-host - harness exists locally, but its clean commit-bound workflow artifact has not yet been produced; +- G0 corrections are not part of the exact RC.2 candidate and require a newly named candidate plus + hosted replay before they can become release evidence; +- all required hosted browser lanes remain `not-configured` for the exact RC.2 candidate; the G4 + workflow can certify later source identities but cannot retroactively promote RC.2; - the crates.io replay and production edge/browser replay need fresh, unexpired, artifact-hashed evidence; - the LTS distribution boundary still lacks signed native binaries, checksums, SBOMs, attestations, diff --git a/docs/product/release-readiness-0.1.0.json b/docs/product/release-readiness-0.1.0.json index 3fb9ff9..9f81fe4 100644 --- a/docs/product/release-readiness-0.1.0.json +++ b/docs/product/release-readiness-0.1.0.json @@ -102,7 +102,7 @@ "status": "pending", "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and hosted replay before they can become release evidence.", + "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" diff --git a/docs/product/release-readiness-0.1.0.md b/docs/product/release-readiness-0.1.0.md index e353a57..4ed6ad3 100644 --- a/docs/product/release-readiness-0.1.0.md +++ b/docs/product/release-readiness-0.1.0.md @@ -74,7 +74,7 @@ CodeQL run 29864046876 passed Actions, JavaScript/TypeScript, and Rust analysis ### g0-corrections-on-candidate -G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and hosted replay before they can become release evidence. +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` diff --git a/docs/site/release-readiness.md b/docs/site/release-readiness.md index f4dca17..43a0697 100644 --- a/docs/site/release-readiness.md +++ b/docs/site/release-readiness.md @@ -16,4 +16,4 @@ Technical: blocked; Operational: blocked; Adoption: blocked; Authorized: blocked ## Current blockers {#blockers} -g0-corrections-on-candidate (pending): G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and 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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree. +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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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/scripts/render-browser-output-certification.mjs b/scripts/render-browser-output-certification.mjs index 404c3d9..4b3e010 100644 --- a/scripts/render-browser-output-certification.mjs +++ b/scripts/render-browser-output-certification.mjs @@ -26,7 +26,7 @@ function tableRows() { const document = `# Browser/output certification v1 -Status: **implemented local harness; hosted 3×3 evidence pending** +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 From 775ee9b10d35a663c5efe4320acf0ef930a84ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 15:16:14 -0500 Subject: [PATCH 06/28] chore: refresh site documentation manifest --- site/src/docs.generated.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/docs.generated.json b/site/src/docs.generated.json index 3ba73dd..f36f7a9 100644 --- a/site/src/docs.generated.json +++ b/site/src/docs.generated.json @@ -874,7 +874,7 @@ }, { "sourcePath": "docs/site/release-readiness.md", - "sourceSha256": "sha256:1c590a940d6876b5f0d2ee180e689eb07ae1412660801352c22da6d72955626b", + "sourceSha256": "sha256:14334fe94cee9a55aca0e9c937bf7d2d160726c2c8da14bedbaba27e60a6854a", "route": "/docs/release-readiness/", "title": "Release truth bound to one exact candidate.", "category": "Evidence", @@ -897,7 +897,7 @@ { "id": "blockers", "title": "Current blockers", - "body": "g0-corrections-on-candidate (pending): G0 corrections exist only in the later uncommitted worktree. They require a reviewed commit/tree and 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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree.", + "body": "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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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 } ] From 970373e4ed3897e04d2c87d418431435697ae705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 15:32:22 -0500 Subject: [PATCH 07/28] test: refresh pruning evidence for shared engine --- docs/benchmarks/reachability-pruning.md | 33 +++++++++++-------- scripts/measure-reachability-pruning.mjs | 42 ++++++++++++------------ 2 files changed, 40 insertions(+), 35 deletions(-) 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/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", }, }; From 715f861870149787b149999f8b13cd12915a7562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 15:40:34 -0500 Subject: [PATCH 08/28] test: refresh media merge evidence for shared engine --- docs/benchmarks/media-query-merging.md | 13 ++++++++--- scripts/measure-media-merge.mjs | 32 +++++++++++++------------- 2 files changed, 26 insertions(+), 19 deletions(-) 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/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, }, }, From dfa022a1bb9329bf171ea22595979ad072dabd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 16:07:08 -0500 Subject: [PATCH 09/28] test: refresh portable artifact evidence for shared engine --- docs/reference/compatibility.md | 2 +- docs/status/portability.md | 272 +++++++++----------------------- scripts/check-portability.mjs | 68 ++++---- 3 files changed, 113 insertions(+), 229 deletions(-) diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index 85cdeb4..c1450c3 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -46,7 +46,7 @@ must pin every direct PliegoCSS dependency to exact version `0.1.0-rc.2`. | 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 | diff --git a/docs/status/portability.md b/docs/status/portability.md index 0380219..48dc418 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 | `a353b52f386f0b896db3c354f45bc0b304dffab1477904a5ee37cd8768be468a` findings | `b8513ecd80e9fe566778aa0bfb0b142ecfb06a014bb34a1caa02c1ad28567fd3` | `81777f9533bb6cd203f68e0f052e5974d09a2d773d7adf8f062f9361100a1c28` | +| Bundle control | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` CSS | `e75b481bd2755a70d5c2ac1030729633cb96d28d8107e562b55ae0951913ba12` | `6fe898d54ad8c745d63d1e6cdd8d31ae6c157130ad4d047542ba6f3cb1f4b5cf` | +| Reopened Asset Plan | `bd9fa74558e57f08cc9bdff0bf6f6fa6502c483487451652795353a14dab579d` findings | `f254eebbfc716351308f56f34d6494e4317866965cbea6d4bf6b27922f0b09ea` | `d79e5cca007ee6af62cbafaa36532503860e6243f7d534398d27a1bde4e40dce` | +| Compile control | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` CSS | `2ab00fa0f7b8a06e7bff3780fdc06d61b26b7136e163f8e6437907ddb013343e` | `3dfb3de854d95b39c7f69325f9eae71b37e62607f9c4e367e8188a63f848da70` | +| Watch control | `65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc` CSS | `537de8064740008b879872d77abfd767352c3a75474b72f79a7d95aab3982c85` | `602c062c391c030112356f996eb5bec251757cdf5ffb59b0f299b575dc869a49` | +| Direct DTCG | `9a349f5059ffb3ace6c985dfe1fadcd9cb9ac45668b5cd0c00e26625e5a6c6c3` CSS | `96a9fb6769b6ba1260e4581528c834d248a7dcb52e9a9be616ed1edfdb686c84` | `62968ba59c63c76ef01c73a8091978c30855ba8d39cf10f6717332786086546d` | -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/scripts/check-portability.mjs b/scripts/check-portability.mjs index 35d6823..8300f25 100644 --- a/scripts/check-portability.mjs +++ b/scripts/check-portability.mjs @@ -39,9 +39,9 @@ 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", @@ -51,84 +51,84 @@ const expectedControlHashes = { "81777f9533bb6cd203f68e0f052e5974d09a2d773d7adf8f062f9361100a1c28", }; 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", + "99a2101ad94c358f743b3df1a1214efe8a7716a9006226e43b74141039804e59", "pliego.css.manifest.json": - "4ac39f4ec4035e4cdf337f900f23eea44b8a52abf951750b3d02e4bd4bd1d76a", + "e75b481bd2755a70d5c2ac1030729633cb96d28d8107e562b55ae0951913ba12", "pliego.css.receipt.json": - "5776c810e022e391505b3efd441b9f3b0219dab32af6d4bc6f5dc4222c8a3ac0", + "6fe898d54ad8c745d63d1e6cdd8d31ae6c157130ad4d047542ba6f3cb1f4b5cf", "pliego.index.json": - "4338ee784241b023b9bbb2b4959187d6f9dddaa75c7c6cdf021ec60346bc1393", + "b685bbaaa08bfac33f9024f96a8a6c8338771ed4d019fe7c01db602ea40a1864", "pliego.tokens.json": - "0a45f1d6e48280ee7b357e2e4d58d82bfc27be715c9fd3f3385098608edd4e42", + "3298873e66ade310459593530c86419c05a973d2404b71882c2d0ae430030a28", }; const expectedAssetPlanControlHashes = { "pliego.css.findings.json": - "3faa43be937eb688dddeb8c72be3a7e3257fc34a0643c4a5b6de51844e03b0ef", + "bd9fa74558e57f08cc9bdff0bf6f6fa6502c483487451652795353a14dab579d", "pliego.css.manifest.json": - "cf15c3f7fc738c1058eaa2471cfb942e62969b7aed9e5cdad599ab7522394488", + "f254eebbfc716351308f56f34d6494e4317866965cbea6d4bf6b27922f0b09ea", "pliego.css.receipt.json": - "d76be0703b140e636ebcc2dacec079e8c5cf893139a317e7429bdce4d3b92cea", + "d79e5cca007ee6af62cbafaa36532503860e6243f7d534398d27a1bde4e40dce", }; const expectedCompileControlHashes = { - "app.css": "acdb0bfee613ddb3fdae0feb9a8e5118dc5f74d385ed53eb610ff4eefd05d454", + "app.css": "65dbbcff74b6886fdd749b1c7c0587eaff06b4f4720405e10716c2b0dfee9bbc", "app.css.map": "39a03b016583b0827ec8bc92a2a1677dcb4d151324dcdd9c77d303af29b1dddf", "app.manifest.json": - "8c1dc690c2cbb8102b6935a970e60938bbda7e50a0c0bf766c5c800305cce598", + "453f708c9c4edbd388eda1ee9c883212cd5d31421077d123cb1d32bc0b28d4bb", "pliego.css.findings.json": - "6e75872fa833f550501f433a7be89bf1b90cf97bf52dc130169851a0c9ea2c52", + "f0bef2cd675d1bf1f4a759c851d9a8be6e4617fdaaa443c00fd3696278691437", "pliego.css.manifest.json": - "9a98a8a78cb9f2597bfa2970a646c0ba4af99838a08e9063e3da85f4176a7cd2", + "2ab00fa0f7b8a06e7bff3780fdc06d61b26b7136e163f8e6437907ddb013343e", "pliego.css.receipt.json": - "a4d9f387b460b650d7c9b904b0f54721f9915c1ccea5753a41b646c503923aa7", + "3dfb3de854d95b39c7f69325f9eae71b37e62607f9c4e367e8188a63f848da70", "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", + "52ccd05b7c6f39520c474a72a5b3e9a6eaa34b7e79fa8f05a759dfacf1ce2852", "pliego.css.manifest.json": - "15831a792506ecf7b97e01907b7fdf1d1e4e2ccf7d90a1c2af105668adab3ed1", + "537de8064740008b879872d77abfd767352c3a75474b72f79a7d95aab3982c85", "pliego.css.receipt.json": - "dc2532037337cfe285ff26162a7b0d404307d2d3c3a159ac95cf4862b9586b29", + "602c062c391c030112356f996eb5bec251757cdf5ffb59b0f299b575dc869a49", "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", + "1915a6ca985813c148b7c8ad4724b3061d26790b2b3bc301a9cf9e029422cb2c", "pliego.css.manifest.json": - "991801df2b11ad17e2adc5003e4e49d13d1c8050f829ab32c99b71965b79b924", + "96a9fb6769b6ba1260e4581528c834d248a7dcb52e9a9be616ed1edfdb686c84", "pliego.css.receipt.json": - "b40d88514e19892b08b6478b94bce582b08dea8fa1d5eed26cfa47f40871af9a", + "62968ba59c63c76ef01c73a8091978c30855ba8d39cf10f6717332786086546d", "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"; @@ -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) { From 6411ec919ad997916ec14ed126a0084a02375e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 16:12:38 -0500 Subject: [PATCH 10/28] fix: bind determinism checks to matrix toolchains --- .github/workflows/ci.yml | 1 + scripts/check-parallel-determinism.mjs | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 321b9e7..c565fb5 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 diff --git a/scripts/check-parallel-determinism.mjs b/scripts/check-parallel-determinism.mjs index 6dbffc7..1610b7c 100644 --- a/scripts/check-parallel-determinism.mjs +++ b/scripts/check-parallel-determinism.mjs @@ -14,9 +14,10 @@ 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: "1.85.0", + toolchain: rustToolchain, }); const cargoTarget = cargoTargetRoot(root, cargoEnvironment); const runtime = join(root, "target", "parallel-determinism", `run-${process.pid}-${Date.now()}`); @@ -103,7 +104,7 @@ 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( cargoTarget, "debug", @@ -120,7 +121,7 @@ function resolveLockHolder() { return configured; } run("cargo", [ - "+1.85.0", + `+${rustToolchain}`, "build", "--locked", "--manifest-path", @@ -464,6 +465,7 @@ async function main() { JSON.stringify( { schemaVersion: 1, + rustToolchain, workersPerCohort: workers, maximumConcurrentCompilerProcesses: workers, referenceProcesses: profiles.length, From 2c8e8a8b19a3cc9be98251a27f0c47b123503879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 16:20:54 -0500 Subject: [PATCH 11/28] fix: harden site Chrome CDP bootstrap --- site/scripts/check-site.mjs | 49 +++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/site/scripts/check-site.mjs b/site/scripts/check-site.mjs index 835acf4..5b08360 100644 --- a/site/scripts/check-site.mjs +++ b/site/scripts/check-site.mjs @@ -334,22 +334,55 @@ async function browserContract() { await new Promise((resolveListen) => 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`) @@ -359,7 +392,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 ( From 7c45ea487d5ba576ce52f136db63114047bdda27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 17:12:01 -0500 Subject: [PATCH 12/28] feat: add repository-only pnpm distribution --- .github/workflows/ci.yml | 2 + .github/workflows/distribution.yml | 254 +++++++++++++ CHANGELOG.md | 7 + README.md | 8 +- THIRD_PARTY_NOTICES.md | 2 +- distribution/repository-distribution-v1.json | 56 +++ ...cher-from-immutable-repository-releases.md | 64 ++++ docs/adr/README.md | 1 + docs/contributing/release-process.md | 42 ++- docs/getting-started/installation.md | 31 +- docs/product/maturity-map.json | 14 +- docs/product/maturity-map.md | 3 +- docs/product/release-readiness-0.1.0.json | 4 +- docs/product/release-readiness-0.1.0.md | 6 +- docs/site/release-readiness.md | 2 +- docs/status/g5.md | 33 ++ package.json | 3 + packages/cli/README.md | 22 ++ packages/cli/bin/launcher.mjs | 71 ++++ packages/cli/bin/pliego-css-lsp.mjs | 10 + packages/cli/bin/pliego-cssc.mjs | 10 + packages/cli/package.json | 41 +++ pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 1 + scripts/build-repository-distribution.mjs | 313 ++++++++++++++++ scripts/check-distribution-bundle.mjs | 286 +++++++++++++++ .../check-installed-repository-package.mjs | 98 +++++ scripts/check-release.mjs | 6 + scripts/check-release.test.mjs | 2 + scripts/check-repository-distribution.mjs | 154 ++++++++ scripts/check-standards-provenance.mjs | 10 +- scripts/prepare-native-distribution.mjs | 123 +++++++ scripts/repository-distribution.mjs | 339 ++++++++++++++++++ site/src/docs.generated.json | 4 +- standards/provenance.json | 8 +- 35 files changed, 1999 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/distribution.yml create mode 100644 distribution/repository-distribution-v1.json create mode 100644 docs/adr/0025-distribute-node-launcher-from-immutable-repository-releases.md create mode 100644 docs/status/g5.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/bin/launcher.mjs create mode 100644 packages/cli/bin/pliego-css-lsp.mjs create mode 100644 packages/cli/bin/pliego-cssc.mjs create mode 100644 packages/cli/package.json create mode 100644 scripts/build-repository-distribution.mjs create mode 100644 scripts/check-distribution-bundle.mjs create mode 100644 scripts/check-installed-repository-package.mjs create mode 100644 scripts/check-repository-distribution.mjs create mode 100644 scripts/prepare-native-distribution.mjs create mode 100644 scripts/repository-distribution.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c565fb5..dd84584 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,8 @@ jobs: run: pnpm check:benchmark-authority - name: Check browser/output certification authority run: pnpm check:browser-output-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 diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml new file mode 100644 index 0000000..9fd5312 --- /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 }}-${{ github.sha }} + 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-*-${{ github.sha }} + path: target/distribution-incoming + merge-multiple: true + - 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/CHANGELOG.md b/CHANGELOG.md index a831efe..aa1fa31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ pre-1.0 compatibility policy. ## [Unreleased] +### 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 diff --git a/README.md b/README.md index ba2c6d9..765294c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,8 @@ 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: @@ -92,6 +93,11 @@ Install the public-preview CLI from crates.io: cargo install pliego-cssc --version '=0.1.0-rc.2' --locked ``` +Later candidates may also attach a repository-only universal pnpm package to their 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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a0e5dbc..17714ca 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -74,7 +74,7 @@ bundled software dependencies. - 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 - `39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394`. + `b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51`. ## Browser-certification software 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/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/README.md b/docs/adr/README.md index 9e1594b..cf20eb9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,3 +26,4 @@ ADRs record decisions that constrain implementation or public behavior. - [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) diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index f4a8af9..cfc540f 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -35,7 +35,8 @@ 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 @@ -358,6 +359,40 @@ 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 schema-3 authority currently binds candidate `0.1.0-rc.2` to commit @@ -371,8 +406,9 @@ passed; they do not clear these remaining gates: workflow can certify later source identities but cannot retroactively promote RC.2; - the crates.io replay and production edge/browser replay need fresh, unexpired, artifact-hashed evidence; -- the LTS distribution boundary still lacks signed native binaries, checksums, SBOMs, attestations, - and the npm launcher; +- RC.2 predates G5 and therefore has no exact-source repository native archives, checksums, + CycloneDX SBOMs, GitHub/Sigstore attestations, immutable GitHub Release, or verified pnpm package; + npmjs publication is forbidden and later-source G5 evidence cannot be inherited by RC.2; - 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. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 94f6f57..5203574 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -13,9 +13,10 @@ 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: @@ -33,6 +34,30 @@ Install the CLI with the same exact candidate: cargo install pliego-cssc --version '=0.1.0-rc.2' --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. For a later candidate whose +release page contains the G5 bundle, 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: diff --git a/docs/product/maturity-map.json b/docs/product/maturity-map.json index 727bdef..8e5fe25 100644 --- a/docs/product/maturity-map.json +++ b/docs/product/maturity-map.json @@ -252,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 5905574..7a4386a 100644 --- a/docs/product/maturity-map.md +++ b/docs/product/maturity-map.md @@ -51,7 +51,8 @@ promise: - migration inventory; - 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 9f81fe4..91f3531 100644 --- a/docs/product/release-readiness-0.1.0.json +++ b/docs/product/release-readiness-0.1.0.json @@ -221,7 +221,7 @@ "status": "blocked", "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet.", + "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" @@ -235,7 +235,7 @@ "node": null, "browser": null }, - "requiredCoverage": ["windows-x86_64", "linux-x86_64", "macos-arm64", "checksums", "sbom", "attestations", "npm-launcher"], + "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": [], diff --git a/docs/product/release-readiness-0.1.0.md b/docs/product/release-readiness-0.1.0.md index 4ed6ad3..7f65899 100644 --- a/docs/product/release-readiness-0.1.0.md +++ b/docs/product/release-readiness-0.1.0.md @@ -40,7 +40,7 @@ Historical reports and release notes do not change this state. | `browser-release-matrix` | technical | **blocked** | pending | 0/5 | 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/7 | none | +| `signed-binary-distribution` | operational | **blocked** | pending | 0/10 | none | | `external-adoption-evidence` | adoption | **pending** | pending | 0/3 | none | | `final-promotion-authorization` | authorization | **not-authorized** | pending | 0/1 | none | @@ -120,11 +120,11 @@ The production site has historical deployment evidence, but a current exact-sour ### signed-binary-distribution -Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. +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`, `sbom`, `attestations`, `npm-launcher` +- 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 diff --git a/docs/site/release-readiness.md b/docs/site/release-readiness.md index 43a0697..280d68d 100644 --- a/docs/site/release-readiness.md +++ b/docs/site/release-readiness.md @@ -16,4 +16,4 @@ Technical: blocked; Operational: blocked; Adoption: blocked; Authorized: blocked ## Current blockers {#blockers} -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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree. +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. 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): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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/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/package.json b/package.json index 5a07496..038a6c5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,9 @@ "check:browser-output": "node ./scripts/browser-output-certification.mjs", "check:browser-output-matrix": "node ./scripts/check-browser-output-matrix.mjs", "check:release-authority": "node ./scripts/release-authority.test.mjs && node ./scripts/check-release-readiness.mjs --validate && node ./scripts/check-hosted-browser-matrix.mjs --validate && node ./scripts/check-document-authority.mjs && pnpm check:site-docs", + "check:repository-distribution": "node ./scripts/check-repository-distribution.mjs", + "check:distribution-bundle": "node ./scripts/check-distribution-bundle.mjs", + "check:distribution-install": "node ./scripts/check-installed-repository-package.mjs", "integration:browser:chrome": "node ./scripts/check-local-cdp-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", "integration:browser:edge": "node ./scripts/check-local-cdp-browser.mjs \"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe\"", "integration:browser:theme-root:chrome": "node ./scripts/check-theme-root-browser.mjs \"C:/Program Files/Google/Chrome/Application/chrome.exe\"", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..8d315cc --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,22 @@ +# PliegoCSS repository CLI package + +This is an npm-format package, but it is **not published to npmjs**. A release +build embeds the supported `pliego-cssc` and `pliego-css-lsp` native binaries +and publishes the resulting `.tgz` only as an immutable GitHub Release asset. + +The package has no dependencies and no lifecycle scripts. Its small Node.js +launchers select one embedded binary without downloading or executing any +installer code. + +Download and verify the exact release asset before installing it locally: + +```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 +``` + +Supported hosts are Windows x64, Linux x64 GNU, and macOS arm64. Cargo remains +the supported installation path for other Rust targets. diff --git a/packages/cli/bin/launcher.mjs b/packages/cli/bin/launcher.mjs new file mode 100644 index 0000000..4a7005a --- /dev/null +++ b/packages/cli/bin/launcher.mjs @@ -0,0 +1,71 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export const SUPPORTED_TARGETS = Object.freeze({ + "darwin:arm64": Object.freeze({ + id: "aarch64-apple-darwin", + extension: "", + }), + "linux:x64": Object.freeze({ + id: "x86_64-unknown-linux-gnu", + extension: "", + }), + "win32:x64": Object.freeze({ + id: "x86_64-pc-windows-msvc", + extension: ".exe", + }), +}); + +const BINARY_NAMES = new Set(["pliego-cssc", "pliego-css-lsp"]); + +export function selectTarget(platform, arch) { + const target = SUPPORTED_TARGETS[`${platform}:${arch}`]; + if (!target) { + const supported = Object.keys(SUPPORTED_TARGETS).sort().join(", "); + throw new Error( + `PliegoCSS has no repository binary for ${platform}:${arch}; supported hosts: ${supported}`, + ); + } + return target; +} + +export function resolveBinary(packageRoot, binaryName, platform, arch) { + if (!BINARY_NAMES.has(binaryName)) { + throw new Error(`unknown PliegoCSS repository binary: ${binaryName}`); + } + const target = selectTarget(platform, arch); + return resolve( + packageRoot, + "vendor", + target.id, + `${binaryName}${target.extension}`, + ); +} + +export function runNative(binaryName, args) { + const binary = resolveBinary( + PACKAGE_ROOT, + binaryName, + process.platform, + process.arch, + ); + if (!existsSync(binary)) { + throw new Error( + `PliegoCSS package is incomplete: ${binaryName} is missing for ${process.platform}:${process.arch}`, + ); + } + const result = spawnSync(binary, args, { + shell: false, + stdio: "inherit", + windowsHide: false, + }); + if (result.error) throw result.error; + if (result.signal) { + throw new Error(`${binaryName} terminated by signal ${result.signal}`); + } + return result.status ?? 1; +} diff --git a/packages/cli/bin/pliego-css-lsp.mjs b/packages/cli/bin/pliego-css-lsp.mjs new file mode 100644 index 0000000..8f8d456 --- /dev/null +++ b/packages/cli/bin/pliego-css-lsp.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { runNative } from "./launcher.mjs"; + +try { + process.exitCode = runNative("pliego-css-lsp", process.argv.slice(2)); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/packages/cli/bin/pliego-cssc.mjs b/packages/cli/bin/pliego-cssc.mjs new file mode 100644 index 0000000..8d021e6 --- /dev/null +++ b/packages/cli/bin/pliego-cssc.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { runNative } from "./launcher.mjs"; + +try { + process.exitCode = runNative("pliego-cssc", process.argv.slice(2)); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..d273d01 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,41 @@ +{ + "name": "@pliegocss/cli", + "version": "0.1.0-rc.2", + "private": true, + "description": "Repository-distributed PliegoCSS CLI and language server launcher", + "type": "module", + "bin": { + "pliego-cssc": "bin/pliego-cssc.mjs", + "pliego-css-lsp": "bin/pliego-css-lsp.mjs" + }, + "files": [ + "bin", + "vendor", + "SBOM", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=22.13", + "pnpm": ">=11.7.0 <12" + }, + "packageManager": "pnpm@11.7.0", + "repository": { + "type": "git", + "url": "git+https://github.com/celiumsai/pliegocss.git", + "directory": "packages/cli" + }, + "homepage": "https://pliegocss.dev", + "bugs": { + "url": "https://github.com/celiumsai/pliegocss/issues" + }, + "license": "Apache-2.0", + "publishConfig": { + "executableFiles": [ + "vendor/aarch64-apple-darwin/pliego-cssc", + "vendor/aarch64-apple-darwin/pliego-css-lsp", + "vendor/x86_64-unknown-linux-gnu/pliego-cssc", + "vendor/x86_64-unknown-linux-gnu/pliego-css-lsp" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85ab5e8..6cbfb57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,8 @@ importers: specifier: 5.9.3 version: 5.9.3 + packages/cli: {} + site: dependencies: gsap: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db0a25d..5d67bdc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - editors/* + - packages/* - site onlyBuiltDependencies: - "@parcel/watcher" diff --git a/scripts/build-repository-distribution.mjs b/scripts/build-repository-distribution.mjs new file mode 100644 index 0000000..0b62431 --- /dev/null +++ b/scripts/build-repository-distribution.mjs @@ -0,0 +1,313 @@ +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 === "--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, 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-distribution-bundle.mjs b/scripts/check-distribution-bundle.mjs new file mode 100644 index 0000000..d420ad9 --- /dev/null +++ b/scripts/check-distribution-bundle.mjs @@ -0,0 +1,286 @@ +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) { + 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-installed-repository-package.mjs b/scripts/check-installed-repository-package.mjs new file mode 100644 index 0000000..a0a05e8 --- /dev/null +++ b/scripts/check-installed-repository-package.mjs @@ -0,0 +1,98 @@ +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) { + 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-release.mjs b/scripts/check-release.mjs index c326812..01f77aa 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -42,6 +42,12 @@ const gates = Object.freeze([ command: "pnpm", args: ["check:browser-output-authority"], }, + { + id: "repository-distribution-contract", + tier: "fast", + command: "pnpm", + args: ["check:repository-distribution"], + }, { id: "rust-tests", tier: "fast", diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs index d9b66fc..87a4e6a 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -41,6 +41,7 @@ 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 === "repository-distribution-contract")); assert(!fast.gates.some((gate) => gate.id === "release-readiness")); assert(!fast.gates.some((gate) => gate.id === "getting-started")); @@ -54,6 +55,7 @@ assert(integration.gates.some((gate) => gate.id === "benchmark-authority-smoke") 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")); diff --git a/scripts/check-repository-distribution.mjs b/scripts/check-repository-distribution.mjs new file mode 100644 index 0000000..2899297 --- /dev/null +++ b/scripts/check-repository-distribution.mjs @@ -0,0 +1,154 @@ +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", + "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, 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-standards-provenance.mjs b/scripts/check-standards-provenance.mjs index 9e10673..0efd661 100644 --- a/scripts/check-standards-provenance.mjs +++ b/scripts/check-standards-provenance.mjs @@ -7,7 +7,7 @@ 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": "8c3afb5b3b6735c2bac0696872df513e372541606ebb117c3349a6cde8a7fc79", + "THIRD_PARTY_NOTICES.md": "0062833f236f77ab9cb7386fc575d7e9e9fbc68abe8e3c75590c4de59e5ff09d", "docs/reference/standards-provenance.md": "b3a0b68daaab8af4d832ff71df0e633d14f4f6052cf21961463c90a8645ddbdf", }); const sourceKeys = [ @@ -31,10 +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":"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"}]}, - {"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"}]}, - {"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"}]}, - {"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:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394"},{"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":"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"}]}, + {"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"}]}, + {"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"}]}, + {"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"},{"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"}]} ]`); diff --git a/scripts/prepare-native-distribution.mjs b/scripts/prepare-native-distribution.mjs new file mode 100644 index 0000000..b3526a7 --- /dev/null +++ b/scripts/prepare-native-distribution.mjs @@ -0,0 +1,123 @@ +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) { + 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/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/site/src/docs.generated.json b/site/src/docs.generated.json index f36f7a9..0d19e7c 100644 --- a/site/src/docs.generated.json +++ b/site/src/docs.generated.json @@ -874,7 +874,7 @@ }, { "sourcePath": "docs/site/release-readiness.md", - "sourceSha256": "sha256:14334fe94cee9a55aca0e9c937bf7d2d160726c2c8da14bedbaba27e60a6854a", + "sourceSha256": "sha256:41e5e4188aaf3d082d3590ac189cc97fe82737228ba14bf308ba92732acc6abb", "route": "/docs/release-readiness/", "title": "Release truth bound to one exact candidate.", "category": "Evidence", @@ -897,7 +897,7 @@ { "id": "blockers", "title": "Current blockers", - "body": "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. 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): Signed native binaries, checksums, SBOMs, attestations, and the npm launcher required by the LTS distribution boundary do not exist yet. external-adoption-evidence (pending): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree.", + "body": "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. 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): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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 } ] diff --git a/standards/provenance.json b/standards/provenance.json index 3a0f873..6f9b517 100644 --- a/standards/provenance.json +++ b/standards/provenance.json @@ -146,7 +146,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" } ] }, @@ -178,7 +178,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" } ] }, @@ -205,7 +205,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" } ] }, @@ -236,7 +236,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:39f1ccd3f3b3262805d773e92bf8b2d0efc9ac755ba77460a0cb801d5ccea394" + "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" }, { "subject": "tailwindcss@3.4.19", From f50874b14c328c0758791b9303dd4b47c3ba7a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 17:20:57 -0500 Subject: [PATCH 13/28] fix: preserve native artifact directories --- .github/workflows/distribution.yml | 6 +++--- scripts/build-repository-distribution.mjs | 2 +- scripts/check-repository-distribution.mjs | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml index 9fd5312..024ebf0 100644 --- a/.github/workflows/distribution.yml +++ b/.github/workflows/distribution.yml @@ -97,7 +97,7 @@ jobs: - name: Upload native payload uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: native-${{ matrix.target }}-${{ github.sha }} + name: native-${{ matrix.target }} path: target/distribution-native/${{ matrix.target }} if-no-files-found: error retention-days: 30 @@ -127,9 +127,9 @@ jobs: - name: Download all native payloads uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: native-*-${{ github.sha }} + pattern: native-* path: target/distribution-incoming - merge-multiple: true + merge-multiple: false - name: Assemble and validate release assets shell: bash run: | diff --git a/scripts/build-repository-distribution.mjs b/scripts/build-repository-distribution.mjs index 0b62431..45881f9 100644 --- a/scripts/build-repository-distribution.mjs +++ b/scripts/build-repository-distribution.mjs @@ -109,7 +109,7 @@ mkdirSync(stageRoot, { recursive: true }); const nativeInputs = []; for (const target of policy.targets) { - const root = resolve(inputRoot, target.id); + 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`); diff --git a/scripts/check-repository-distribution.mjs b/scripts/check-repository-distribution.mjs index 2899297..b4093d8 100644 --- a/scripts/check-repository-distribution.mjs +++ b/scripts/check-repository-distribution.mjs @@ -39,6 +39,7 @@ for (const required of [ "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271", "version: 11.7.0", "actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6", + "merge-multiple: false", "repos/${GITHUB_REPOSITORY}/immutable-releases", "--draft", ]) { @@ -106,7 +107,7 @@ try { `--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, target.id))}`, + `--output=${relative(ROOT, resolve(fixtureInput, `native-${target.id}`))}`, `--source-commit=${sourceCommit}`, `--git-tree=${gitTree}`, ]); From cffbdf868bc52d5708e750e989a9c147e578e4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 17:29:44 -0500 Subject: [PATCH 14/28] fix: accept pnpm argument separator --- scripts/build-repository-distribution.mjs | 1 + scripts/check-distribution-bundle.mjs | 1 + scripts/check-installed-repository-package.mjs | 1 + scripts/check-repository-distribution.mjs | 1 + scripts/prepare-native-distribution.mjs | 1 + 5 files changed, 5 insertions(+) diff --git a/scripts/build-repository-distribution.mjs b/scripts/build-repository-distribution.mjs index 45881f9..ab68ba1 100644 --- a/scripts/build-repository-distribution.mjs +++ b/scripts/build-repository-distribution.mjs @@ -31,6 +31,7 @@ import { function parse(args) { const value = { allowDirty: false }; for (const arg of args) { + if (arg === "--") continue; if (arg === "--allow-dirty") { value.allowDirty = true; continue; diff --git a/scripts/check-distribution-bundle.mjs b/scripts/check-distribution-bundle.mjs index d420ad9..521b893 100644 --- a/scripts/check-distribution-bundle.mjs +++ b/scripts/check-distribution-bundle.mjs @@ -19,6 +19,7 @@ import { 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]; diff --git a/scripts/check-installed-repository-package.mjs b/scripts/check-installed-repository-package.mjs index a0a05e8..1240f2d 100644 --- a/scripts/check-installed-repository-package.mjs +++ b/scripts/check-installed-repository-package.mjs @@ -7,6 +7,7 @@ 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]; diff --git a/scripts/check-repository-distribution.mjs b/scripts/check-repository-distribution.mjs index b4093d8..c03b8e1 100644 --- a/scripts/check-repository-distribution.mjs +++ b/scripts/check-repository-distribution.mjs @@ -121,6 +121,7 @@ try { ]); const result = JSON.parse( run("check-distribution-bundle.mjs", [ + "--", `--root=${relative(ROOT, resolve(fixtureOutput, "release-assets"))}`, ]), ); diff --git a/scripts/prepare-native-distribution.mjs b/scripts/prepare-native-distribution.mjs index b3526a7..3326c36 100644 --- a/scripts/prepare-native-distribution.mjs +++ b/scripts/prepare-native-distribution.mjs @@ -24,6 +24,7 @@ import { 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]; From c08bea4ea31ca955971404471fc038e746eabd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 19:00:30 -0500 Subject: [PATCH 15/28] feat: certify G6 adapter coexistence --- .github/workflows/adapter-coexistence.yml | 131 +++ .github/workflows/ci.yml | 2 + .../adapter-coexistence-v1/authority.json | 158 ++++ benchmarks/adapter-coexistence-v1/corpus.json | 174 ++++ crates/pliego-css-source/src/migration.rs | 65 +- docs/adr/0026-certify-adapter-coexistence.md | 60 ++ docs/adr/README.md | 1 + docs/benchmarks/adapter-coexistence-v1.md | 54 ++ .../representative-vite-tailwind.md | 9 +- docs/product/maturity-map.json | 14 +- docs/product/maturity-map.md | 3 +- docs/product/release-readiness-0.1.0.json | 31 + docs/product/release-readiness-0.1.0.md | 13 + .../representative-applications-gap-map.json | 46 +- docs/reference/adapter-coexistence.md | 72 ++ .../reference/migration-inventory-schema-1.md | 8 +- .../reversible-migration-plan-schema-1.md | 6 + docs/site/release-readiness.md | 2 +- .../pliegors-smoke/pliegors-contract.json | 4 +- .../pliegors-smoke/src/bin/ssg.rs | 2 +- package.json | 6 +- pnpm-lock.yaml | 285 +++++++ scripts/adapter-coexistence-certification.mjs | 748 ++++++++++++++++++ scripts/adapter-coexistence-v1.mjs | 154 ++++ scripts/adapter-coexistence-v1.test.mjs | 72 ++ .../check-adapter-coexistence-authority.mjs | 153 ++++ scripts/check-adapter-coexistence-matrix.mjs | 300 +++++++ .../check-adapter-coexistence-matrix.test.mjs | 174 ++++ scripts/check-pliegors-browser.mjs | 51 +- scripts/check-release.mjs | 13 + scripts/check-representative-gap-map.mjs | 2 +- scripts/release-authority.mjs | 1 + site/src/docs.generated.json | 4 +- 33 files changed, 2768 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/adapter-coexistence.yml create mode 100644 benchmarks/adapter-coexistence-v1/authority.json create mode 100644 benchmarks/adapter-coexistence-v1/corpus.json create mode 100644 docs/adr/0026-certify-adapter-coexistence.md create mode 100644 docs/benchmarks/adapter-coexistence-v1.md create mode 100644 docs/reference/adapter-coexistence.md create mode 100644 scripts/adapter-coexistence-certification.mjs create mode 100644 scripts/adapter-coexistence-v1.mjs create mode 100644 scripts/adapter-coexistence-v1.test.mjs create mode 100644 scripts/check-adapter-coexistence-authority.mjs create mode 100644 scripts/check-adapter-coexistence-matrix.mjs create mode 100644 scripts/check-adapter-coexistence-matrix.test.mjs diff --git a/.github/workflows/adapter-coexistence.yml b/.github/workflows/adapter-coexistence.yml new file mode 100644 index 0000000..aa185d5 --- /dev/null +++ b/.github/workflows/adapter-coexistence.yml @@ -0,0 +1,131 @@ +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 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: celiumsai/pliegors + ref: cc61a41b49ab60eb76e82fedebccf17fe00bdc0c + path: target/upstream/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 + - name: Install PliegoRS Rust toolchain + run: | + rustup toolchain install 1.86.0 --profile minimal + rustup target add wasm32-unknown-unknown --toolchain 1.86.0 + - name: Execute pinned PliegoRS browser gate + run: pnpm integration:pliegors-browser + env: + PLIEGORS_ROOT: ${{ github.workspace }}\target\upstream\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: 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/ci.yml b/.github/workflows/ci.yml index dd84584..3bdaffd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,8 @@ jobs: 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 repository-only pnpm distribution contract run: pnpm check:repository-distribution - name: Smoke Benchmark Authority v2 runner 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/crates/pliego-css-source/src/migration.rs b/crates/pliego-css-source/src/migration.rs index 13ff210..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, @@ -4306,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) } @@ -4541,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, @@ -4588,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/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/README.md b/docs/adr/README.md index cf20eb9..b0d3e36 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,3 +27,4 @@ ADRs record decisions that constrain implementation or public behavior. - [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) 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/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/product/maturity-map.json b/docs/product/maturity-map.json index 8e5fe25..888939f 100644 --- a/docs/product/maturity-map.json +++ b/docs/product/maturity-map.json @@ -186,14 +186,14 @@ }, { "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", "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"], + "gates": ["check:migration-corpus", "check:migration-real-corpus", "check:adapter-coexistence-authority", "check:adapter-coexistence", "check:adapter-coexistence-matrix", "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", diff --git a/docs/product/maturity-map.md b/docs/product/maturity-map.md index 7a4386a..343c87d 100644 --- a/docs/product/maturity-map.md +++ b/docs/product/maturity-map.md @@ -48,7 +48,8 @@ promise: - 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; - watch mode; - PliegoRS SSR/SSG/resumability integration; - exact-version crates.io distribution for the Rust workspace plus repository-hosted native CLI/LSP diff --git a/docs/product/release-readiness-0.1.0.json b/docs/product/release-readiness-0.1.0.json index 91f3531..1d0bd4c 100644 --- a/docs/product/release-readiness-0.1.0.json +++ b/docs/product/release-readiness-0.1.0.json @@ -153,6 +153,37 @@ } ] }, + { + "id": "adapter-coexistence-matrix", + "dimension": "technical", + "status": "blocked", + "evidenceClass": "pending", + "requiredForPromotion": true, + "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": "registry-replay-refresh", "dimension": "technical", diff --git a/docs/product/release-readiness-0.1.0.md b/docs/product/release-readiness-0.1.0.md index 7f65899..2c8c030 100644 --- a/docs/product/release-readiness-0.1.0.md +++ b/docs/product/release-readiness-0.1.0.md @@ -38,6 +38,7 @@ Historical reports and release notes do not change this state. | `exact-candidate-codeql` | technical | **passed** | measured | 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 | @@ -94,6 +95,18 @@ Every required hosted browser lane is not configured for the exact candidate sou - 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. diff --git a/docs/product/representative-applications-gap-map.json b/docs/product/representative-applications-gap-map.json index 5f9a907..0554d7a 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-22", "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": "Owned fixtures now close the bounded adapter protocol, but 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/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/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/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/site/release-readiness.md b/docs/site/release-readiness.md index 280d68d..39a17e8 100644 --- a/docs/site/release-readiness.md +++ b/docs/site/release-readiness.md @@ -16,4 +16,4 @@ Technical: blocked; Operational: blocked; Adoption: blocked; Authorized: blocked ## Current blockers {#blockers} -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. 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): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree. +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): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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/integration-tests/pliegors-smoke/pliegors-contract.json b/integration-tests/pliegors-smoke/pliegors-contract.json index 993b4cb..63e8013 100644 --- a/integration-tests/pliegors-smoke/pliegors-contract.json +++ b/integration-tests/pliegors-smoke/pliegors-contract.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "revision": "b00259c12fb5b069b2a50461bd244c9f098e834b", - "sourceSha256": "7b1588be7a5c6a8036a50c47fafbf3322ab7eec9f335156aa1ef869567f8a9a4", + "revision": "cc61a41b49ab60eb76e82fedebccf17fe00bdc0c", + "sourceSha256": "3cd1efe5e2a34fabd7954b10522575a2000c81b144595b3cf8d5780415b2dac8", "surfaces": [ "Cargo.toml", "Cargo.lock", 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/package.json b/package.json index 038a6c5..8d76214 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "check:browser-output-authority": "node ./scripts/check-browser-output-authority.mjs && node ./scripts/check-browser-output-matrix.test.mjs && node ./scripts/render-browser-output-certification.mjs --check", "check:browser-output": "node ./scripts/browser-output-certification.mjs", "check:browser-output-matrix": "node ./scripts/check-browser-output-matrix.mjs", + "check:adapter-coexistence-authority": "node ./scripts/check-adapter-coexistence-authority.mjs && node ./scripts/adapter-coexistence-v1.test.mjs && node ./scripts/check-adapter-coexistence-matrix.test.mjs", + "check:adapter-coexistence": "node ./scripts/adapter-coexistence-certification.mjs", + "check:adapter-coexistence-matrix": "node ./scripts/check-adapter-coexistence-matrix.mjs", "check:release-authority": "node ./scripts/release-authority.test.mjs && node ./scripts/check-release-readiness.mjs --validate && node ./scripts/check-hosted-browser-matrix.mjs --validate && node ./scripts/check-document-authority.mjs && pnpm check:site-docs", "check:repository-distribution": "node ./scripts/check-repository-distribution.mjs", "check:distribution-bundle": "node ./scripts/check-distribution-bundle.mjs", @@ -110,6 +113,7 @@ "tailwindcss": "4.3.3", "tailwindcss-frozen": "npm:tailwindcss@4.3.2", "tailwindcss-v3-lts": "npm:tailwindcss@3.4.19", - "three": "0.185.1" + "three": "0.185.1", + "vite": "8.1.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cbfb57..f462f84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: three: specifier: 0.185.1 version: 0.185.1 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@24.10.0)(esbuild@0.28.1)(jiti@1.21.7) editors/vscode: dependencies: @@ -202,9 +205,18 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -533,6 +545,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -545,6 +563,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -642,6 +663,104 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@secretlint/config-creator@10.2.2': resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} engines: {node: '>=20.0.0'} @@ -899,6 +1018,9 @@ packages: '@textlint/types@15.7.1': resolution: {integrity: sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/node@24.10.0': resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} @@ -2027,6 +2149,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -2297,6 +2424,49 @@ packages: resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} engines: {node: '>=4'} + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vscode-jsonrpc@9.0.1: resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} engines: {node: '>=14.0.0'} @@ -2508,11 +2678,27 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -2711,6 +2897,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2723,6 +2916,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxc-project/types@0.139.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -2795,6 +2990,57 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@secretlint/config-creator@10.2.2': dependencies: '@secretlint/types': 10.2.2 @@ -3046,6 +3292,11 @@ snapshots: dependencies: '@textlint/ast-node-types': 15.7.1 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/node@24.10.0': dependencies: undici-types: 7.16.0 @@ -4183,6 +4434,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -4512,6 +4784,19 @@ snapshots: version-range@4.15.0: {} + vite@8.1.5(@types/node@24.10.0)(esbuild@0.28.1)(jiti@1.21.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.21 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.10.0 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 1.21.7 + vscode-jsonrpc@9.0.1: {} vscode-languageclient@10.1.0: diff --git a/scripts/adapter-coexistence-certification.mjs b/scripts/adapter-coexistence-certification.mjs new file mode 100644 index 0000000..1b45709 --- /dev/null +++ b/scripts/adapter-coexistence-certification.mjs @@ -0,0 +1,748 @@ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { chromium } from "playwright"; +import { build as viteBuild } from "vite"; +import { + authorityPath, + decorateClassNodes, + extractStaticClassGroups, + hostFor, + loadContract, + profileById, + readJson, + renderProjectDocument, + repositoryRoot, + rewriteStaticClassGroups, + sha256, +} from "./adapter-coexistence-v1.mjs"; +import { targetDirectoryForRustcIdentity } from "./rust-target.mjs"; + +function fail(message) { + throw new Error(`adapter coexistence certification: ${message}`); +} + +function parseOptions(arguments_) { + const options = { browser: "chromium", 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/adapter-coexistence-certification.mjs [--browser=chromium] [--host=ID] [--output=PATH] [--require-clean]", + ); + } + } + if (options.browser !== "chromium") fail("schema 1 certifies Chromium only"); + return options; +} + +function run(command, arguments_, options = {}) { + const result = spawnSync(command, arguments_, { + cwd: repositoryRoot, + encoding: "utf8", + windowsHide: true, + timeout: 600_000, + maxBuffer: 64 * 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(-16_384)}`, + ); + } + return result; +} + +function git(arguments_) { + return run("git", arguments_).stdout; +} + +function gitState(requireClean) { + const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]); + if (requireClean && status.length !== 0) fail("--require-clean found a dirty source tree"); + 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 prepareCompiler() { + const rustcVerbose = run("rustc", ["+1.96.0", "-vV"]).stdout.replaceAll("\r\n", "\n").trim(); + const host = /^host: (\S+)$/mu.exec(rustcVerbose)?.[1]; + if (!host) fail("cannot resolve the 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 themeCss() { + return `@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 v3Config() { + return `module.exports = {\n content: ["./index.html"],\n corePlugins: { preflight: false },\n theme: { extend: {\n colors: { canvas: "oklch(98.5% .004 80)", surface: "#fff", "surface-raised": "oklch(96% .006 80)", ink: "oklch(19% .012 70)", muted: "oklch(50% .014 70)", accent: "oklch(58% .19 32)", "accent-strong": "oklch(50% .18 30)", line: "oklch(88% .008 75)" },\n fontFamily: { sans: ["ui-sans-serif", "system-ui", "sans-serif"] },\n boxShadow: { sm: "0 1px 2px #0000000d" }\n } }\n};\n`; +} + +function compileTailwind(profile, stage) { + const input = join(stage, "tailwind.input.css"); + const output = join(stage, "tailwind.css"); + const environment = { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" }; + if (profile.id === "tailwind-v3-lts") { + writeFileSync(input, "@tailwind utilities;\n"); + writeFileSync(join(stage, "tailwind.config.cjs"), v3Config()); + run( + process.execPath, + [ + resolve(repositoryRoot, "node_modules", profile.packageAlias, "lib", "cli.js"), + "-i", + "tailwind.input.css", + "-o", + "tailwind.css", + "--minify", + "-c", + "tailwind.config.cjs", + ], + { cwd: stage, env: environment }, + ); + } else if (profile.id === "tailwind-v4-current") { + writeFileSync( + input, + `@layer theme, utilities;\n@import "${profile.packageAlias}/theme.css" layer(theme);\n@import "${profile.packageAlias}/utilities.css" layer(utilities) source(none);\n@source "./index.html";\n${themeCss()}`, + ); + const cli = resolve( + repositoryRoot, + "node_modules", + profile.cliPackageAlias, + "dist", + "index.mjs", + ); + run(process.execPath, [cli, "-i", "tailwind.input.css", "-o", "tailwind.css", "--minify"], { + cwd: stage, + env: environment, + }); + } else { + fail(`unsupported Tailwind profile ${profile.id}`); + } + const bytes = readFileSync(output); + if (bytes.length === 0) fail(`${profile.id} emitted empty CSS`); + return { + input: readFileSync(input), + output: bytes, + inputSha256: sha256(readFileSync(input)), + outputSha256: sha256(bytes), + outputBytes: bytes.length, + }; +} + +function auditTailwind(compiler, stage) { + const control = join(stage, "tailwind-audit-control"); + mkdirSync(control, { recursive: true }); + const arguments_ = [ + "audit", + "--input", + "tailwind.css", + "--targets", + "modern", + "--format", + "json", + "--control-dir", + "tailwind-audit-control", + ]; + const first = run(compiler.executable, arguments_, { cwd: stage }); + const check = run(compiler.executable, [...arguments_, "--check"], { cwd: stage }); + if (first.stdout !== check.stdout) fail("Tailwind output audit changed in --check mode"); + const report = JSON.parse(first.stdout); + const findings = report.findings ?? []; + if (findings.some((finding) => finding.severity === "error")) { + fail("Tailwind output audit contains a blocking error"); + } + const files = [ + "pliego.css.findings.json", + "pliego.css.manifest.json", + "pliego.css.receipt.json", + ]; + return { + reportSha256: sha256(Buffer.from(first.stdout)), + findings: findings.length, + severities: Object.fromEntries( + ["error", "warning", "info"].map((severity) => [ + severity, + findings.filter((finding) => finding.severity === severity).length, + ]), + ), + codes: [...new Set(findings.map((finding) => finding.code))].sort(), + control: Object.fromEntries( + files.map((file) => { + const bytes = readFileSync(join(control, file)); + return [file, { bytes: bytes.length, sha256: sha256(bytes) }]; + }), + ), + }; +} + +function inventoryTailwindSource(compiler, stage, profile) { + const arguments_ = ["migration-inventory", "tailwind", "tailwind.input.css"]; + const first = run(compiler.executable, arguments_, { cwd: stage }); + const second = run(compiler.executable, arguments_, { cwd: stage }); + if (first.stderr !== "" || second.stderr !== "" || first.stdout !== second.stdout) { + fail(`${profile.id} source inventory is not deterministic and clean`); + } + const inventory = JSON.parse(first.stdout); + const kinds = inventory.constructs?.map((construct) => construct.kind) ?? []; + if ( + inventory.schemaVersion !== 1 || + inventory.sourceKind !== "tailwind" || + inventory.preflightReliance !== "not-observed" || + inventory.summary?.dynamic !== 0 || + inventory.summary?.unsupported !== 0 || + (profile.id === "tailwind-v3-lts" && !kinds.includes("tailwind-layer")) || + (profile.id === "tailwind-v4-current" && !kinds.includes("tailwind-import")) + ) { + fail(`${profile.id} source inventory did not match its bounded v3/v4 contract`); + } + return { + sha256: sha256(Buffer.from(first.stdout)), + bytes: Buffer.byteLength(first.stdout), + constructs: inventory.summary.constructs, + dynamic: inventory.summary.dynamic, + unsupported: inventory.summary.unsupported, + preflightReliance: inventory.preflightReliance, + kinds: [...new Set(kinds)].sort(), + }; +} + +function compilePliego(compiler, stage, document) { + const { uniqueGroups } = extractStaticClassGroups(document, "staged project"); + writeFileSync(join(stage, "styles.txt"), `${uniqueGroups.join("\n")}\n`); + run( + compiler.executable, + [ + "compile", + "--input", + "styles.txt", + "--seed", + "--theme", + "--targets", + "modern", + "--output", + "pliego.after.css", + "--manifest", + "pliego.manifest.json", + ], + { cwd: stage }, + ); + const css = readFileSync(join(stage, "pliego.after.css")); + const manifestBytes = readFileSync(join(stage, "pliego.manifest.json")); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + if (manifest.styles?.length !== uniqueGroups.length) { + fail(`Pliego manifest expected ${uniqueGroups.length} styles, found ${manifest.styles?.length}`); + } + return { + css, + cssSha256: sha256(css), + manifest, + manifestSha256: sha256(manifestBytes), + styleGroups: uniqueGroups.length, + }; +} + +function prepareMigration(compiler, stage, document, pliego) { + const rewrite = rewriteStaticClassGroups(document, pliego.manifest, "staged project"); + writeFileSync(join(stage, "document.after.html"), rewrite.document); + writeFileSync(join(stage, "pliego.css"), Buffer.alloc(0)); + const group = { + schemaVersion: 1, + entries: [ + { file: "index.html", after: "document.after.html" }, + { file: "pliego.css", after: "pliego.after.css" }, + ], + }; + const groupBytes = Buffer.from(`${JSON.stringify(group, null, 2)}\n`); + writeFileSync(join(stage, "migration.group.json"), groupBytes); + const before = { + document: readFileSync(join(stage, "index.html")), + pliego: readFileSync(join(stage, "pliego.css")), + tailwind: readFileSync(join(stage, "tailwind.css")), + }; + run( + compiler.executable, + [ + "migration-group-apply", + "--manifest", + "migration.group.json", + "--receipt", + "migration.receipt.json", + ], + { cwd: stage }, + ); + const receiptBytes = readFileSync(join(stage, "migration.receipt.json")); + const receipt = JSON.parse(receiptBytes.toString("utf8")); + const after = { + document: readFileSync(join(stage, "index.html")), + pliego: readFileSync(join(stage, "pliego.css")), + tailwind: readFileSync(join(stage, "tailwind.css")), + }; + if ( + sha256(after.document) !== sha256(Buffer.from(rewrite.document)) || + sha256(after.pliego) !== pliego.cssSha256 || + sha256(after.tailwind) !== sha256(before.tailwind) + ) { + fail("applied migration bytes do not match the approved group"); + } + return { + rewrite, + groupSha256: sha256(groupBytes), + receipt, + receiptSha256: sha256(receiptBytes), + before, + after, + }; +} + +function rollbackMigration(compiler, stage, migration) { + run( + compiler.executable, + ["migration-group-rollback", "--receipt", "migration.receipt.json"], + { cwd: stage }, + ); + const restored = { + document: readFileSync(join(stage, "index.html")), + pliego: readFileSync(join(stage, "pliego.css")), + tailwind: readFileSync(join(stage, "tailwind.css")), + }; + for (const key of Object.keys(restored)) { + if (sha256(restored[key]) !== sha256(migration.before[key])) { + fail(`rollback did not restore exact ${key} bytes`); + } + } + try { + statSync(join(stage, "migration.receipt.json")); + fail("rollback left the migration receipt behind"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + return { + exact: true, + documentSha256: sha256(restored.document), + pliegoSha256: sha256(restored.pliego), + tailwindSha256: sha256(restored.tailwind), + }; +} + +async function viteBuildSnapshot(stage, outDir) { + await viteBuild({ + root: stage, + configFile: false, + logLevel: "silent", + base: "./", + build: { + outDir, + emptyOutDir: true, + minify: false, + cssMinify: false, + }, + }); + const root = resolve(stage, outDir); + const htmlBytes = readFileSync(join(root, "index.html")); + const html = htmlBytes.toString("utf8"); + const hrefs = [...html.matchAll(/]+rel="stylesheet"[^>]+href="([^"]+\.css)"[^>]*>/gu)].map( + (match) => match[1], + ); + if (hrefs.length === 0) fail("Vite output has no stylesheet asset"); + const css = Buffer.concat( + hrefs.map((href) => readFileSync(resolve(root, href.replace(/^\.\//u, "")))), + ); + return { + html, + htmlSha256: sha256(htmlBytes), + css, + cssSha256: sha256(css), + assets: listFiles(root).map((path) => { + const bytes = readFileSync(path); + return { + path: relative(root, path).replaceAll("\\", "/"), + bytes: bytes.length, + sha256: sha256(bytes), + }; + }), + }; +} + +function listFiles(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() ? listFiles(path) : [path]; + }); +} + +function injectCss(document, css) { + const withoutLinks = document.replace(/]+rel="stylesheet"[^>]*>\s*/gu, ""); + const deterministic = + "*,:before,:after{animation-duration:0s!important;animation-delay:0s!important;transition-duration:0s!important;caret-color:transparent!important}"; + return withoutLinks.replace( + "", + ``, + ); +} + +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/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-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-release.mjs b/scripts/check-release.mjs index 01f77aa..7c5ef06 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -42,6 +42,12 @@ const gates = Object.freeze([ command: "pnpm", args: ["check:browser-output-authority"], }, + { + id: "adapter-coexistence-authority", + tier: "fast", + command: "pnpm", + args: ["check:adapter-coexistence-authority"], + }, { id: "repository-distribution-contract", tier: "fast", @@ -228,6 +234,13 @@ 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", 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/release-authority.mjs b/scripts/release-authority.mjs index 9ea4a54..052b055 100644 --- a/scripts/release-authority.mjs +++ b/scripts/release-authority.mjs @@ -193,6 +193,7 @@ export function validateReleaseReadiness(value, { root, now = Date.now(), verify "exact-candidate-codeql", "g0-corrections-on-candidate", "browser-release-matrix", + "adapter-coexistence-matrix", "registry-replay-refresh", "production-deployment-refresh", "signed-binary-distribution", diff --git a/site/src/docs.generated.json b/site/src/docs.generated.json index 0d19e7c..dd70e15 100644 --- a/site/src/docs.generated.json +++ b/site/src/docs.generated.json @@ -874,7 +874,7 @@ }, { "sourcePath": "docs/site/release-readiness.md", - "sourceSha256": "sha256:41e5e4188aaf3d082d3590ac189cc97fe82737228ba14bf308ba92732acc6abb", + "sourceSha256": "sha256:d4574180ec81b0c7d592883cebf374b54d5f4677bfc6e4a1dc148f249df2c5e4", "route": "/docs/release-readiness/", "title": "Release truth bound to one exact candidate.", "category": "Evidence", @@ -897,7 +897,7 @@ { "id": "blockers", "title": "Current blockers", - "body": "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. 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): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree.", + "body": "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): The required external interviews, real incidents, and pilots have not been recorded as reviewed 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 } ] From dcb2c8394a032a03552379a14bfe19c1a29190b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 19:02:37 -0500 Subject: [PATCH 16/28] fix: bind provenance to G6 lockfile --- scripts/check-standards-provenance.mjs | 8 ++++---- standards/provenance.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/check-standards-provenance.mjs b/scripts/check-standards-provenance.mjs index 0efd661..e611ef8 100644 --- a/scripts/check-standards-provenance.mjs +++ b/scripts/check-standards-provenance.mjs @@ -31,10 +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":"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"}]}, - {"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"}]}, - {"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"}]}, - {"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:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51"},{"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":"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"}]} ]`); diff --git a/standards/provenance.json b/standards/provenance.json index 6f9b517..28f34ca 100644 --- a/standards/provenance.json +++ b/standards/provenance.json @@ -146,7 +146,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" } ] }, @@ -178,7 +178,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" } ] }, @@ -205,7 +205,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" } ] }, @@ -236,7 +236,7 @@ }, { "subject": "pnpm-lock.yaml", - "value": "sha256:b841e850a294c98603dd98fa8ba847ecd67d4fa490439726ac07939635ac2a51" + "value": "sha256:e973a4a2f250b37b0b83c994d77223b89ab35e14f4bc4aec984fbe2c9efbfeb5" }, { "subject": "tailwindcss@3.4.19", From 5f227ea8808976b29d8f1b555100b745e5ea75f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 19:04:20 -0500 Subject: [PATCH 17/28] fix: isolate PliegoRS hosted checkout --- .github/workflows/adapter-coexistence.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/adapter-coexistence.yml b/.github/workflows/adapter-coexistence.yml index aa185d5..881115f 100644 --- a/.github/workflows/adapter-coexistence.yml +++ b/.github/workflows/adapter-coexistence.yml @@ -63,11 +63,12 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + path: pliegocss - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: celiumsai/pliegors ref: cc61a41b49ab60eb76e82fedebccf17fe00bdc0c - path: target/upstream/pliegors + path: pliegors - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22.13.0 @@ -75,21 +76,23 @@ jobs: 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 target add wasm32-unknown-unknown --toolchain 1.86.0 - name: Execute pinned PliegoRS browser gate run: pnpm integration:pliegors-browser + working-directory: pliegocss env: - PLIEGORS_ROOT: ${{ github.workspace }}\target\upstream\pliegors + 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: target/adapter-coexistence-certification/pliegors-framework.json + path: pliegocss/target/adapter-coexistence-certification/pliegors-framework.json if-no-files-found: error retention-days: 30 From 8548cbe71273aafec92f9d850e91c72fbfc0415c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 19:12:56 -0500 Subject: [PATCH 18/28] fix: install pinned wasm-bindgen for PliegoRS --- .github/workflows/adapter-coexistence.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/adapter-coexistence.yml b/.github/workflows/adapter-coexistence.yml index 881115f..dbac45b 100644 --- a/.github/workflows/adapter-coexistence.yml +++ b/.github/workflows/adapter-coexistence.yml @@ -81,6 +81,10 @@ jobs: run: | rustup toolchain install 1.86.0 --profile minimal rustup target add wasm32-unknown-unknown --toolchain 1.86.0 + - name: Install pinned wasm-bindgen CLI + run: | + cargo +1.86.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 From b766fce7093694c65ff60e89269a5845b42db8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Wed, 22 Jul 2026 19:16:40 -0500 Subject: [PATCH 19/28] fix: build wasm-bindgen CLI with current Rust --- .github/workflows/adapter-coexistence.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/adapter-coexistence.yml b/.github/workflows/adapter-coexistence.yml index dbac45b..50c4af7 100644 --- a/.github/workflows/adapter-coexistence.yml +++ b/.github/workflows/adapter-coexistence.yml @@ -80,10 +80,11 @@ jobs: - 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.86.0 install wasm-bindgen-cli --version 0.2.126 --locked --root "$env:RUNNER_TEMP\wasm-bindgen-cli" + 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 From d0a190be555c940a6c64922defff81067f21a14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Guti=C3=A9rrez?= Date: Sat, 25 Jul 2026 15:25:06 -0500 Subject: [PATCH 20/28] feat: govern G7 external adoption evidence --- .github/workflows/ci.yml | 2 + .gitignore | 1 + EXECUTION_PLAN_ULTRA.md | 5 +- SUPPORT.md | 4 + .../external-adoption-v1/authority.json | 65 ++ .../external-adoption-v1/incidents.json | 6 + .../external-adoption-v1/interviews.json | 6 + benchmarks/external-adoption-v1/pilots.json | 6 + .../0027-govern-external-adoption-evidence.md | 41 + docs/adr/README.md | 1 + docs/benchmarks/methodology.md | 3 +- docs/contributing/external-adoption-intake.md | 68 ++ docs/contributing/release-process.md | 2 + docs/index.md | 3 + .../product/adapter-support-policy-0.1.x.json | 74 ++ docs/product/document-authority.json | 3 + docs/product/external-adoption-g7.md | 50 ++ docs/product/maturity-map.json | 6 +- docs/product/maturity-map.md | 3 +- docs/product/release-readiness-0.1.0.json | 4 +- docs/product/release-readiness-0.1.0.md | 6 +- .../representative-applications-gap-map.json | 4 +- .../representative-applications-gap-map.md | 49 +- .../reference/adapter-support-policy-0.1.x.md | 45 ++ docs/site/external-adoption.md | 19 + docs/site/release-readiness.md | 2 +- package.json | 2 + scripts/check-external-adoption.mjs | 30 + scripts/check-release-readiness.mjs | 46 +- scripts/check-release.mjs | 12 + scripts/check-release.test.mjs | 2 + scripts/external-adoption-v1.mjs | 705 ++++++++++++++++++ scripts/external-adoption-v1.test.mjs | 247 ++++++ scripts/render-external-adoption.mjs | 123 +++ site/scripts/check-site.mjs | 21 +- site/scripts/generate-docs.test.mjs | 6 +- site/src/docs.generated.json | 34 +- 37 files changed, 1661 insertions(+), 45 deletions(-) create mode 100644 benchmarks/external-adoption-v1/authority.json create mode 100644 benchmarks/external-adoption-v1/incidents.json create mode 100644 benchmarks/external-adoption-v1/interviews.json create mode 100644 benchmarks/external-adoption-v1/pilots.json create mode 100644 docs/adr/0027-govern-external-adoption-evidence.md create mode 100644 docs/contributing/external-adoption-intake.md create mode 100644 docs/product/adapter-support-policy-0.1.x.json create mode 100644 docs/product/external-adoption-g7.md create mode 100644 docs/reference/adapter-support-policy-0.1.x.md create mode 100644 docs/site/external-adoption.md create mode 100644 scripts/check-external-adoption.mjs create mode 100644 scripts/external-adoption-v1.mjs create mode 100644 scripts/external-adoption-v1.test.mjs create mode 100644 scripts/render-external-adoption.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bdaffd..8acbfc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,6 +256,8 @@ jobs: 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 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/EXECUTION_PLAN_ULTRA.md b/EXECUTION_PLAN_ULTRA.md index a3a6ec0..0ec0de2 100644 --- a/EXECUTION_PLAN_ULTRA.md +++ b/EXECUTION_PLAN_ULTRA.md @@ -462,7 +462,10 @@ Toda optimización tiene benchmark; ninguna complejidad entra solamente por intu Otro gate de red fija tres proyectos oficiales MIT: 119 archivos revisados producen 104 TP, 0 FP y 0 FN para discovery archivo–rol. Esa métrica no cubre precisión semántica, codemods ni resultados de migración. -- [ ] Entrevistar 10–15 developers/equipos y reunir 20 incidentes reales anonimizados. +- [ ] Entrevistar 10–15 developers/equipos y reunir 20 incidentes reales anonimizados. G7 ya tiene + autoridad schema 1, ledgers públicos hash-bound, custodia privada/redacción/consentimiento, + verificador fail-closed y política de adapters 0.1.x congelada; al 2026-07-25 siguen en + 0 entrevistas, 0 incidentes y 0 pilotos externos admitidos, por lo que el gate permanece bloqueado. - [x] Medir precision/recall y falsos positivos de discovery archivo–rol sobre el corpus público fijado; resultado 104 TP / 0 FP / 0 FN en 119 archivos. - [ ] Medir precision/recall semántico, excepciones y tiempo de resolución por categoría sobre un diff --git a/SUPPORT.md b/SUPPORT.md index badee16..18f5ff2 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,7 @@ 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). Vulnerabilities must follow [SECURITY.md](SECURITY.md). PliegoCSS is provided under Apache-2.0 without a support SLA or warranty. diff --git a/benchmarks/external-adoption-v1/authority.json b/benchmarks/external-adoption-v1/authority.json new file mode 100644 index 0000000..9e09b22 --- /dev/null +++ b/benchmarks/external-adoption-v1/authority.json @@ -0,0 +1,65 @@ +{ + "schemaVersion": 1, + "status": "active-contract", + "kind": "pliegocss-external-adoption-authority", + "studyId": "g7-external-adoption-0.1", + "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/docs/adr/0027-govern-external-adoption-evidence.md b/docs/adr/0027-govern-external-adoption-evidence.md new file mode 100644 index 0000000..3458134 --- /dev/null +++ b/docs/adr/0027-govern-external-adoption-evidence.md @@ -0,0 +1,41 @@ +# 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. + +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. +- 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/README.md b/docs/adr/README.md index b0d3e36..dd0f081 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,3 +28,4 @@ ADRs record decisions that constrain implementation or public behavior. - [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) diff --git a/docs/benchmarks/methodology.md b/docs/benchmarks/methodology.md index 7e4e8c5..7c805d5 100644 --- a/docs/benchmarks/methodology.md +++ b/docs/benchmarks/methodology.md @@ -146,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/contributing/external-adoption-intake.md b/docs/contributing/external-adoption-intake.md new file mode 100644 index 0000000..8cf1e9c --- /dev/null +++ b/docs/contributing/external-adoption-intake.md @@ -0,0 +1,68 @@ +# 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). + +## 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. + +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 cfc540f..ce176b5 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -109,6 +109,8 @@ 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 diff --git a/docs/index.md b/docs/index.md index 45dd479..5e0ee67 100644 --- a/docs/index.md +++ b/docs/index.md @@ -92,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) @@ -101,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) @@ -146,6 +148,7 @@ owned route bundles. `pliego-cssc watch` only regenerates artifacts; composed wi - [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/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 index a852f3c..dd8423b 100644 --- a/docs/product/document-authority.json +++ b/docs/product/document-authority.json @@ -14,6 +14,8 @@ "EXECUTION_PLAN_ULTRA.md", "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": [ @@ -38,6 +40,7 @@ "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.", "The website status route consumes the generated Markdown instead of duplicating release prose in Rust." ] } diff --git a/docs/product/external-adoption-g7.md b/docs/product/external-adoption-g7.md new file mode 100644 index 0000000..b3b039f --- /dev/null +++ b/docs/product/external-adoption-g7.md @@ -0,0 +1,50 @@ +# 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. + +## 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 888939f..a676253 100644 --- a/docs/product/maturity-map.json +++ b/docs/product/maturity-map.json @@ -189,9 +189,9 @@ "title": "Migration inventory and static adapter coexistence", "maturity": "beta", "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", "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"], - "gates": ["check:migration-corpus", "check:migration-real-corpus", "check:adapter-coexistence-authority", "check:adapter-coexistence", "check:adapter-coexistence-matrix", "integration:pliegors-browser"], + "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."] }, diff --git a/docs/product/maturity-map.md b/docs/product/maturity-map.md index 343c87d..a56853a 100644 --- a/docs/product/maturity-map.md +++ b/docs/product/maturity-map.md @@ -49,7 +49,8 @@ promise: 3×3 host matrix; - LSP/editor clients; - migration inventory plus bounded static Tailwind v3/v4 coexistence across 21 HTML, Vite, and - PliegoRS project snapshots with browser equivalence and exact rollback; + 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 crates.io distribution for the Rust workspace plus repository-hosted native CLI/LSP diff --git a/docs/product/release-readiness-0.1.0.json b/docs/product/release-readiness-0.1.0.json index 1d0bd4c..8ff2be7 100644 --- a/docs/product/release-readiness-0.1.0.json +++ b/docs/product/release-readiness-0.1.0.json @@ -278,7 +278,7 @@ "status": "pending", "evidenceClass": "pending", "requiredForPromotion": true, - "summary": "The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence.", + "summary": "RC.2 predates the G7 authority. The required external interviews, real incidents, and pilots have not been recorded as consented, redacted, reviewed, hash-bound evidence.", "source": { "commit": "064dcbce96a3a5cc97a940d07566c008bb5e2d3e", "gitTree": "010a64f5d0bbd142a1d015fda4ccf573cc6e4133" @@ -292,7 +292,7 @@ "node": null, "browser": null }, - "requiredCoverage": ["ten-to-fifteen-interviews", "twenty-incidents", "three-to-five-pilots"], + "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": [], diff --git a/docs/product/release-readiness-0.1.0.md b/docs/product/release-readiness-0.1.0.md index 2c8c030..fd6f769 100644 --- a/docs/product/release-readiness-0.1.0.md +++ b/docs/product/release-readiness-0.1.0.md @@ -42,7 +42,7 @@ Historical reports and release notes do not change this state. | `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/3 | none | +| `external-adoption-evidence` | adoption | **pending** | pending | 0/6 | none | | `final-promotion-authorization` | authorization | **not-authorized** | pending | 0/1 | none | ## Evidence and blockers @@ -143,11 +143,11 @@ RC.2 predates the repository-only G5 distribution contract. It has no exact-sour ### external-adoption-evidence -The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. +RC.2 predates the G7 authority. 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` +- 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 diff --git a/docs/product/representative-applications-gap-map.json b/docs/product/representative-applications-gap-map.json index 0554d7a..266e217 100644 --- a/docs/product/representative-applications-gap-map.json +++ b/docs/product/representative-applications-gap-map.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "statusDate": "2026-07-22", + "statusDate": "2026-07-25", "applications": [ { "id": "plain-html-audit-first", @@ -44,7 +44,7 @@ "id": "external-pilot-evidence", "releaseImpact": "blocking", "exposedBy": ["plain-html-audit-first", "vite-tailwind-inventory-first", "framework-neutral-multiroute"], - "reason": "Owned fixtures now close the bounded adapter protocol, but LTS promotion still needs independent projects and maintainers to validate adoption, rollback, and integration behavior." + "reason": "The schema-1 G7 authority and 0.1.x adapter policy are now frozen, but their reviewed ledgers contain zero external records. LTS promotion still needs independent projects and maintainers to validate adoption, rollback, and integration behavior." }, { "rank": 2, diff --git a/docs/product/representative-applications-gap-map.md b/docs/product/representative-applications-gap-map.md index 4490107..53afbb2 100644 --- a/docs/product/representative-applications-gap-map.md +++ b/docs/product/representative-applications-gap-map.md @@ -1,34 +1,35 @@ # 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. +1. **External pilot evidence** — release blocking. The authority and frozen support policy exist, but + the reviewed ledgers currently contain zero interviews, incidents, or pilots. +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. -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. +## G7 boundary -## Evidence 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. -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. +The selected next gap remains `external-pilot-evidence` until real participants satisfy it. 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/site/external-adoption.md b/docs/site/external-adoption.md new file mode 100644 index 0000000..6fff5cc --- /dev/null +++ b/docs/site/external-adoption.md @@ -0,0 +1,19 @@ + + +# 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. + +## 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/release-readiness.md b/docs/site/release-readiness.md index 39a17e8..e3d90b6 100644 --- a/docs/site/release-readiness.md +++ b/docs/site/release-readiness.md @@ -16,4 +16,4 @@ Technical: blocked; Operational: blocked; Adoption: blocked; Authorized: blocked ## Current blockers {#blockers} -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): The required external interviews, real incidents, and pilots have not been recorded as reviewed evidence. final-promotion-authorization (not-authorized): Mario has not explicitly authorized publishing final 0.1.0 for this exact source commit and Git tree. +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. 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/package.json b/package.json index 8d76214..f5c83cc 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "check:adapter-coexistence-authority": "node ./scripts/check-adapter-coexistence-authority.mjs && node ./scripts/adapter-coexistence-v1.test.mjs && node ./scripts/check-adapter-coexistence-matrix.test.mjs", "check:adapter-coexistence": "node ./scripts/adapter-coexistence-certification.mjs", "check:adapter-coexistence-matrix": "node ./scripts/check-adapter-coexistence-matrix.mjs", + "check:external-adoption-authority": "node ./scripts/check-external-adoption.mjs --validate && node ./scripts/external-adoption-v1.test.mjs && node ./scripts/render-external-adoption.mjs --check", + "check:external-adoption": "node ./scripts/check-external-adoption.mjs --require-promotion-ready", "check:release-authority": "node ./scripts/release-authority.test.mjs && node ./scripts/check-release-readiness.mjs --validate && node ./scripts/check-hosted-browser-matrix.mjs --validate && node ./scripts/check-document-authority.mjs && pnpm check:site-docs", "check:repository-distribution": "node ./scripts/check-repository-distribution.mjs", "check:distribution-bundle": "node ./scripts/check-distribution-bundle.mjs", 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-release-readiness.mjs b/scripts/check-release-readiness.mjs index 0522d41..dfa8da5 100644 --- a/scripts/check-release-readiness.mjs +++ b/scripts/check-release-readiness.mjs @@ -1,6 +1,10 @@ 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 args = process.argv.slice(2); @@ -22,7 +26,47 @@ try { readFileSync(resolve(root, "docs/product/release-readiness-0.1.0.json"), "utf8"), ); const summary = validateReleaseReadiness(value, { root }); - process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + 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 (adoptionCheck.status === "passed" && externalAdoption.result !== "ready") { + throw new AuthorityError( + `external-adoption-evidence is passed while G7 is blocked by ${externalAdoption.missingCoverage.join(", ")}`, + ); + } + 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`, diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 7c5ef06..c5748e1 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -48,6 +48,12 @@ const gates = Object.freeze([ 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", @@ -301,6 +307,12 @@ const gates = Object.freeze([ 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", diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs index 87a4e6a..e02a546 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -41,6 +41,7 @@ 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")); @@ -64,6 +65,7 @@ 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"], diff --git a/scripts/external-adoption-v1.mjs b/scripts/external-adoption-v1.mjs new file mode 100644 index 0000000..00e8ea7 --- /dev/null +++ b/scripts/external-adoption-v1.mjs @@ -0,0 +1,705 @@ +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 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, + ); + 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 (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, + ); + 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 (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", + "source", + "outcomes", + "review", + ], + 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`); + } + 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", + "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.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, + }, + 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..90e8cd7 --- /dev/null +++ b/scripts/external-adoption-v1.test.mjs @@ -0,0 +1,247 @@ +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, + }, + 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 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, + }, + 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/render-external-adoption.mjs b/scripts/render-external-adoption.mjs new file mode 100644 index 0000000..395f17b --- /dev/null +++ b/scripts/render-external-adoption.mjs @@ -0,0 +1,123 @@ +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.", + "", + "## 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}.`, + "", + "## 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/site/scripts/check-site.mjs b/site/scripts/check-site.mjs index 5b08360..2994288 100644 --- a/site/scripts/check-site.mjs +++ b/site/scripts/check-site.mjs @@ -82,6 +82,7 @@ function staticContract() { "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", @@ -99,6 +100,7 @@ function staticContract() { "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", @@ -213,7 +215,7 @@ function staticContract() { const sitemap = readFileSync(join(output, "sitemap.xml"), "utf8"); const sitemapUrls = sitemap.match(//gu)?.length ?? 0; if ( - sitemapUrls !== 90 || + sitemapUrls !== 92 || !sitemap.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"') || !sitemap.includes("https://pliegocss.dev/es/legal/privacy/") ) { @@ -228,6 +230,9 @@ function staticContract() { 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", @@ -242,6 +247,20 @@ function staticContract() { ) { 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("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 laboratory = JSON.parse( readFileSync(join(output, "assets", "laboratory.json")), ); diff --git a/site/scripts/generate-docs.test.mjs b/site/scripts/generate-docs.test.mjs index 823460b..94087ca 100644 --- a/site/scripts/generate-docs.test.mjs +++ b/site/scripts/generate-docs.test.mjs @@ -6,9 +6,9 @@ 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, 30); -assert.equal(new Set(manifest.documents.map((document) => document.route)).size, 30); -assert.equal(manifest.documents.at(-1).route, "/docs/release-readiness/"); +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 = `